Skip to main content

doublecrypt_core/
fs.rs

1use std::sync::Arc;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use rand::RngCore;
5
6use crate::allocator::{BitmapAllocator, SlotAllocator};
7use crate::block_store::BlockStore;
8use crate::codec::{
9    read_encrypted_object, read_encrypted_raw, write_encrypted_object, write_encrypted_raw,
10    ObjectCodec, PostcardCodec,
11};
12use crate::crypto::CryptoEngine;
13use crate::error::{FsError, FsResult};
14use crate::model::*;
15use crate::transaction::TransactionManager;
16
17/// The main filesystem core. Owns the block store, crypto, codec, allocator,
18/// and transaction manager. Provides high-level filesystem operations.
19pub struct FilesystemCore {
20    store: Arc<dyn BlockStore>,
21    crypto: Arc<dyn CryptoEngine>,
22    codec: PostcardCodec,
23    allocator: BitmapAllocator,
24    txn: TransactionManager,
25    /// Cached current superblock.
26    superblock: Option<Superblock>,
27    /// Next inode ID to allocate.
28    next_inode_id: InodeId,
29}
30
31/// Maximum payload size for a single file data chunk.
32/// Computed conservatively: block_size minus overhead for envelope framing.
33/// We'll compute this dynamically based on block size.
34fn max_chunk_payload(block_size: usize) -> usize {
35    // Rough overhead: 4 bytes length prefix, ~60 bytes envelope metadata,
36    // 16 bytes Poly1305 tag, some postcard framing. Be conservative.
37    if block_size > 200 {
38        block_size - 200
39    } else {
40        0
41    }
42}
43
44fn now_secs() -> u64 {
45    SystemTime::now()
46        .duration_since(UNIX_EPOCH)
47        .unwrap_or_default()
48        .as_secs()
49}
50
51impl FilesystemCore {
52    /// Create a new FilesystemCore backed by the given store and crypto engine.
53    pub fn new(store: Arc<dyn BlockStore>, crypto: Arc<dyn CryptoEngine>) -> Self {
54        let total_blocks = store.total_blocks();
55        Self {
56            store,
57            crypto,
58            codec: PostcardCodec,
59            allocator: BitmapAllocator::new(total_blocks),
60            txn: TransactionManager::new(),
61            superblock: None,
62            next_inode_id: 1,
63        }
64    }
65
66    // ── Initialization ──
67
68    /// Initialize a brand-new filesystem on the block store.
69    /// Writes the storage header, creates the root directory, and commits.
70    pub fn init_filesystem(&mut self) -> FsResult<()> {
71        let block_size = self.store.block_size() as u32;
72        let total_blocks = self.store.total_blocks();
73
74        // Write storage header to block 0 (unencrypted).
75        let header = StorageHeader::new(block_size, total_blocks);
76        let header_bytes = self.codec.serialize_object(&header)?;
77        let bs = self.store.block_size();
78        let mut block = vec![0u8; bs];
79        rand::thread_rng().fill_bytes(&mut block);
80        let len = header_bytes.len() as u32;
81        block[..4].copy_from_slice(&len.to_le_bytes());
82        block[4..4 + header_bytes.len()].copy_from_slice(&header_bytes);
83        self.store.write_block(BLOCK_STORAGE_HEADER, &block)?;
84
85        // Create root directory inode.
86        let root_inode_id = self.alloc_inode_id();
87        let dir_page = DirectoryPage::new();
88        let dir_page_block = self.allocator.allocate()?;
89        write_encrypted_object(
90            self.store.as_ref(),
91            self.crypto.as_ref(),
92            &self.codec,
93            dir_page_block,
94            ObjectKind::DirectoryPage,
95            &dir_page,
96        )?;
97
98        let ts = now_secs();
99        let root_inode = Inode {
100            id: root_inode_id,
101            kind: InodeKind::Directory,
102            size: 0,
103            directory_page_ref: ObjectRef::new(dir_page_block),
104            extent_map_ref: ObjectRef::null(),
105            created_at: ts,
106            modified_at: ts,
107        };
108        let root_inode_block = self.allocator.allocate()?;
109        write_encrypted_object(
110            self.store.as_ref(),
111            self.crypto.as_ref(),
112            &self.codec,
113            root_inode_block,
114            ObjectKind::Inode,
115            &root_inode,
116        )?;
117
118        // Create superblock.
119        let sb = Superblock {
120            generation: 1,
121            root_inode_ref: ObjectRef::new(root_inode_block),
122        };
123        self.superblock = Some(sb.clone());
124
125        // Commit.
126        self.txn.commit(
127            self.store.as_ref(),
128            self.crypto.as_ref(),
129            &self.codec,
130            &self.allocator,
131            &sb,
132        )?;
133
134        Ok(())
135    }
136
137    /// Open / mount an existing filesystem by recovering the latest root pointer.
138    pub fn open(&mut self) -> FsResult<()> {
139        // Verify storage header.
140        let header = self.read_storage_header()?;
141        if !header.is_valid() {
142            return Err(FsError::InvalidSuperblock);
143        }
144
145        // Recover latest root pointer.
146        let (rp, was_b) = TransactionManager::recover_latest(self.store.as_ref(), &self.codec)?
147            .ok_or(FsError::InvalidRootPointer)?;
148
149        // Read superblock.
150        let sb: Superblock = read_encrypted_object(
151            self.store.as_ref(),
152            self.crypto.as_ref(),
153            &self.codec,
154            rp.superblock_ref.block_id,
155        )?;
156
157        // Verify checksum.
158        let sb_bytes = self.codec.serialize_object(&sb)?;
159        let checksum = blake3::hash(&sb_bytes);
160        if *checksum.as_bytes() != rp.checksum {
161            return Err(FsError::InvalidSuperblock);
162        }
163
164        self.txn = TransactionManager::from_recovered(rp.generation, was_b);
165        self.superblock = Some(sb.clone());
166
167        // Rebuild allocator knowledge by walking the metadata tree.
168        self.rebuild_allocator(&sb)?;
169
170        Ok(())
171    }
172
173    // ── File operations ──
174
175    /// Create a new empty file in the given directory (by inode ref).
176    /// For V1, only root directory is supported.
177    pub fn create_file(&mut self, name: &str) -> FsResult<()> {
178        self.validate_name(name)?;
179        let sb = self
180            .superblock
181            .as_ref()
182            .ok_or(FsError::NotInitialized)?
183            .clone();
184
185        // Load root inode and its directory page.
186        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
187        let mut dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
188
189        // Check for duplicate.
190        if dir_page.entries.iter().any(|e| e.name == name) {
191            return Err(FsError::FileAlreadyExists(name.to_string()));
192        }
193
194        // Create empty extent map for the file.
195        let extent_map = ExtentMap::new();
196        let em_block = self.allocator.allocate()?;
197        self.write_obj(em_block, ObjectKind::ExtentMap, &extent_map)?;
198
199        // Create file inode.
200        let inode_id = self.alloc_inode_id();
201        let ts = now_secs();
202        let file_inode = Inode {
203            id: inode_id,
204            kind: InodeKind::File,
205            size: 0,
206            directory_page_ref: ObjectRef::null(),
207            extent_map_ref: ObjectRef::new(em_block),
208            created_at: ts,
209            modified_at: ts,
210        };
211        let inode_block = self.allocator.allocate()?;
212        self.write_obj(inode_block, ObjectKind::Inode, &file_inode)?;
213
214        // Add directory entry.
215        dir_page.entries.push(DirectoryEntry {
216            name: name.to_string(),
217            inode_ref: ObjectRef::new(inode_block),
218            inode_id,
219            kind: InodeKind::File,
220        });
221
222        // Write updated directory page (CoW: new block).
223        let new_dp_block = self.allocator.allocate()?;
224        self.write_obj(new_dp_block, ObjectKind::DirectoryPage, &dir_page)?;
225
226        // Write updated root inode (CoW).
227        let mut new_root = root_inode.clone();
228        new_root.directory_page_ref = ObjectRef::new(new_dp_block);
229        new_root.modified_at = now_secs();
230        let new_root_block = self.allocator.allocate()?;
231        self.write_obj(new_root_block, ObjectKind::Inode, &new_root)?;
232
233        // Update and commit superblock.
234        let new_sb = Superblock {
235            generation: sb.generation + 1,
236            root_inode_ref: ObjectRef::new(new_root_block),
237        };
238        self.commit_superblock(new_sb)?;
239
240        Ok(())
241    }
242
243    /// Write data to a file. For V1, this replaces the entire file content
244    /// (single chunk only if it fits in one block).
245    pub fn write_file(&mut self, name: &str, offset: u64, data: &[u8]) -> FsResult<()> {
246        let sb = self
247            .superblock
248            .as_ref()
249            .ok_or(FsError::NotInitialized)?
250            .clone();
251        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
252        let dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
253
254        let entry = dir_page
255            .entries
256            .iter()
257            .find(|e| e.name == name)
258            .ok_or_else(|| FsError::FileNotFound(name.to_string()))?;
259
260        if entry.kind != InodeKind::File {
261            return Err(FsError::NotAFile(name.to_string()));
262        }
263
264        let file_inode: Inode = self.read_obj(entry.inode_ref.block_id)?;
265        let mut extent_map: ExtentMap = self.read_obj(file_inode.extent_map_ref.block_id)?;
266
267        // V1: support write-at-offset by building a full buffer.
268        // Read existing data (if any) and splice in the new data.
269        let mut buf = self.read_all_chunks(&extent_map)?;
270
271        let end = offset as usize + data.len();
272        if end > buf.len() {
273            buf.resize(end, 0);
274        }
275        buf[offset as usize..end].copy_from_slice(data);
276
277        let total_size = buf.len();
278
279        // Re-chunk the data. Each chunk must fit in max_chunk_payload.
280        let chunk_size = max_chunk_payload(self.store.block_size());
281        if chunk_size == 0 {
282            return Err(FsError::DataTooLarge(total_size));
283        }
284
285        let mut new_entries = Vec::new();
286        for (i, chunk_data) in buf.chunks(chunk_size).enumerate() {
287            let data_block = self.allocator.allocate()?;
288            write_encrypted_raw(
289                self.store.as_ref(),
290                self.crypto.as_ref(),
291                &self.codec,
292                data_block,
293                ObjectKind::FileDataChunk,
294                chunk_data,
295            )?;
296            new_entries.push(ExtentEntry {
297                chunk_index: i as u64,
298                data_ref: ObjectRef::new(data_block),
299                plaintext_len: chunk_data.len() as u32,
300            });
301        }
302
303        // TODO: free old data blocks (deferred GC).
304        extent_map.entries = new_entries;
305
306        // Write updated extent map (CoW).
307        let new_em_block = self.allocator.allocate()?;
308        self.write_obj(new_em_block, ObjectKind::ExtentMap, &extent_map)?;
309
310        // Write updated file inode.
311        let mut new_file_inode = file_inode.clone();
312        new_file_inode.size = total_size as u64;
313        new_file_inode.extent_map_ref = ObjectRef::new(new_em_block);
314        new_file_inode.modified_at = now_secs();
315        let new_inode_block = self.allocator.allocate()?;
316        self.write_obj(new_inode_block, ObjectKind::Inode, &new_file_inode)?;
317
318        // Update directory entry to point to new inode block.
319        let mut new_dir_page = dir_page.clone();
320        for e in &mut new_dir_page.entries {
321            if e.name == name {
322                e.inode_ref = ObjectRef::new(new_inode_block);
323            }
324        }
325        let new_dp_block = self.allocator.allocate()?;
326        self.write_obj(new_dp_block, ObjectKind::DirectoryPage, &new_dir_page)?;
327
328        // Update root inode.
329        let mut new_root = root_inode.clone();
330        new_root.directory_page_ref = ObjectRef::new(new_dp_block);
331        new_root.modified_at = now_secs();
332        let new_root_block = self.allocator.allocate()?;
333        self.write_obj(new_root_block, ObjectKind::Inode, &new_root)?;
334
335        let new_sb = Superblock {
336            generation: sb.generation + 1,
337            root_inode_ref: ObjectRef::new(new_root_block),
338        };
339        self.commit_superblock(new_sb)?;
340
341        Ok(())
342    }
343
344    /// Read file data. Returns the requested slice of the file.
345    pub fn read_file(&self, name: &str, offset: u64, len: usize) -> FsResult<Vec<u8>> {
346        let sb = self.superblock.as_ref().ok_or(FsError::NotInitialized)?;
347        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
348        let dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
349
350        let entry = dir_page
351            .entries
352            .iter()
353            .find(|e| e.name == name)
354            .ok_or_else(|| FsError::FileNotFound(name.to_string()))?;
355
356        if entry.kind != InodeKind::File {
357            return Err(FsError::NotAFile(name.to_string()));
358        }
359
360        let file_inode: Inode = self.read_obj(entry.inode_ref.block_id)?;
361        let extent_map: ExtentMap = self.read_obj(file_inode.extent_map_ref.block_id)?;
362
363        let full_data = self.read_all_chunks(&extent_map)?;
364
365        let start = offset as usize;
366        if start >= full_data.len() {
367            return Ok(Vec::new());
368        }
369        let end = std::cmp::min(start + len, full_data.len());
370        Ok(full_data[start..end].to_vec())
371    }
372
373    /// List entries in the root directory.
374    pub fn list_directory(&self) -> FsResult<Vec<DirListEntry>> {
375        let sb = self.superblock.as_ref().ok_or(FsError::NotInitialized)?;
376        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
377        let dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
378
379        let mut result = Vec::new();
380        for entry in &dir_page.entries {
381            let inode: Inode = self.read_obj(entry.inode_ref.block_id)?;
382            result.push(DirListEntry {
383                name: entry.name.clone(),
384                kind: entry.kind,
385                size: inode.size,
386                inode_id: entry.inode_id,
387            });
388        }
389        Ok(result)
390    }
391
392    /// Create a subdirectory in the root directory.
393    pub fn create_directory(&mut self, name: &str) -> FsResult<()> {
394        self.validate_name(name)?;
395        let sb = self
396            .superblock
397            .as_ref()
398            .ok_or(FsError::NotInitialized)?
399            .clone();
400        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
401        let mut dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
402
403        if dir_page.entries.iter().any(|e| e.name == name) {
404            return Err(FsError::DirectoryAlreadyExists(name.to_string()));
405        }
406
407        // Create empty directory page for the new subdirectory.
408        let sub_dp = DirectoryPage::new();
409        let sub_dp_block = self.allocator.allocate()?;
410        self.write_obj(sub_dp_block, ObjectKind::DirectoryPage, &sub_dp)?;
411
412        // Create directory inode.
413        let inode_id = self.alloc_inode_id();
414        let ts = now_secs();
415        let dir_inode = Inode {
416            id: inode_id,
417            kind: InodeKind::Directory,
418            size: 0,
419            directory_page_ref: ObjectRef::new(sub_dp_block),
420            extent_map_ref: ObjectRef::null(),
421            created_at: ts,
422            modified_at: ts,
423        };
424        let inode_block = self.allocator.allocate()?;
425        self.write_obj(inode_block, ObjectKind::Inode, &dir_inode)?;
426
427        dir_page.entries.push(DirectoryEntry {
428            name: name.to_string(),
429            inode_ref: ObjectRef::new(inode_block),
430            inode_id,
431            kind: InodeKind::Directory,
432        });
433
434        // CoW commit chain.
435        let new_dp_block = self.allocator.allocate()?;
436        self.write_obj(new_dp_block, ObjectKind::DirectoryPage, &dir_page)?;
437
438        let mut new_root = root_inode.clone();
439        new_root.directory_page_ref = ObjectRef::new(new_dp_block);
440        new_root.modified_at = now_secs();
441        let new_root_block = self.allocator.allocate()?;
442        self.write_obj(new_root_block, ObjectKind::Inode, &new_root)?;
443
444        let new_sb = Superblock {
445            generation: sb.generation + 1,
446            root_inode_ref: ObjectRef::new(new_root_block),
447        };
448        self.commit_superblock(new_sb)?;
449
450        Ok(())
451    }
452
453    /// Remove a file from the root directory.
454    pub fn remove_file(&mut self, name: &str) -> FsResult<()> {
455        let sb = self
456            .superblock
457            .as_ref()
458            .ok_or(FsError::NotInitialized)?
459            .clone();
460        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
461        let mut dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
462
463        let idx = dir_page
464            .entries
465            .iter()
466            .position(|e| e.name == name)
467            .ok_or_else(|| FsError::FileNotFound(name.to_string()))?;
468
469        let entry = &dir_page.entries[idx];
470        if entry.kind == InodeKind::Directory {
471            // Check if directory is empty.
472            let dir_inode: Inode = self.read_obj(entry.inode_ref.block_id)?;
473            let sub_page: DirectoryPage = self.read_obj(dir_inode.directory_page_ref.block_id)?;
474            if !sub_page.entries.is_empty() {
475                return Err(FsError::DirectoryNotEmpty(name.to_string()));
476            }
477        }
478
479        // TODO: free blocks belonging to the removed file (deferred GC).
480        dir_page.entries.remove(idx);
481
482        // CoW commit chain.
483        let new_dp_block = self.allocator.allocate()?;
484        self.write_obj(new_dp_block, ObjectKind::DirectoryPage, &dir_page)?;
485
486        let mut new_root = root_inode.clone();
487        new_root.directory_page_ref = ObjectRef::new(new_dp_block);
488        new_root.modified_at = now_secs();
489        let new_root_block = self.allocator.allocate()?;
490        self.write_obj(new_root_block, ObjectKind::Inode, &new_root)?;
491
492        let new_sb = Superblock {
493            generation: sb.generation + 1,
494            root_inode_ref: ObjectRef::new(new_root_block),
495        };
496        self.commit_superblock(new_sb)?;
497
498        Ok(())
499    }
500
501    /// Rename a file or directory within the root directory.
502    pub fn rename(&mut self, old_name: &str, new_name: &str) -> FsResult<()> {
503        self.validate_name(new_name)?;
504        let sb = self
505            .superblock
506            .as_ref()
507            .ok_or(FsError::NotInitialized)?
508            .clone();
509        let root_inode: Inode = self.read_obj(sb.root_inode_ref.block_id)?;
510        let mut dir_page: DirectoryPage = self.read_obj(root_inode.directory_page_ref.block_id)?;
511
512        // Check that new_name doesn't already exist.
513        if dir_page.entries.iter().any(|e| e.name == new_name) {
514            return Err(FsError::FileAlreadyExists(new_name.to_string()));
515        }
516
517        let entry = dir_page
518            .entries
519            .iter_mut()
520            .find(|e| e.name == old_name)
521            .ok_or_else(|| FsError::FileNotFound(old_name.to_string()))?;
522
523        entry.name = new_name.to_string();
524
525        // CoW commit chain.
526        let new_dp_block = self.allocator.allocate()?;
527        self.write_obj(new_dp_block, ObjectKind::DirectoryPage, &dir_page)?;
528
529        let mut new_root = root_inode.clone();
530        new_root.directory_page_ref = ObjectRef::new(new_dp_block);
531        new_root.modified_at = now_secs();
532        let new_root_block = self.allocator.allocate()?;
533        self.write_obj(new_root_block, ObjectKind::Inode, &new_root)?;
534
535        let new_sb = Superblock {
536            generation: sb.generation + 1,
537            root_inode_ref: ObjectRef::new(new_root_block),
538        };
539        self.commit_superblock(new_sb)?;
540
541        Ok(())
542    }
543
544    /// Sync / flush. Calls through to the block store sync.
545    pub fn sync(&self) -> FsResult<()> {
546        self.store.sync()
547    }
548
549    // ── Internal helpers ──
550
551    fn alloc_inode_id(&mut self) -> InodeId {
552        let id = self.next_inode_id;
553        self.next_inode_id += 1;
554        id
555    }
556
557    fn validate_name(&self, name: &str) -> FsResult<()> {
558        if name.is_empty() || name.contains('/') || name.contains('\0') {
559            return Err(FsError::Internal("invalid name".into()));
560        }
561        if name.len() > MAX_NAME_LEN {
562            return Err(FsError::NameTooLong(name.len(), MAX_NAME_LEN));
563        }
564        Ok(())
565    }
566
567    fn read_obj<T: serde::de::DeserializeOwned>(&self, block_id: u64) -> FsResult<T> {
568        read_encrypted_object(
569            self.store.as_ref(),
570            self.crypto.as_ref(),
571            &self.codec,
572            block_id,
573        )
574    }
575
576    fn write_obj<T: serde::Serialize>(
577        &self,
578        block_id: u64,
579        kind: ObjectKind,
580        obj: &T,
581    ) -> FsResult<()> {
582        write_encrypted_object(
583            self.store.as_ref(),
584            self.crypto.as_ref(),
585            &self.codec,
586            block_id,
587            kind,
588            obj,
589        )
590    }
591
592    fn read_all_chunks(&self, extent_map: &ExtentMap) -> FsResult<Vec<u8>> {
593        let mut entries = extent_map.entries.clone();
594        entries.sort_by_key(|e| e.chunk_index);
595
596        let mut buf = Vec::new();
597        for entry in &entries {
598            let chunk = read_encrypted_raw(
599                self.store.as_ref(),
600                self.crypto.as_ref(),
601                &self.codec,
602                entry.data_ref.block_id,
603            )?;
604            // Only take plaintext_len bytes (chunk may have been decrypted from padded block).
605            let len = entry.plaintext_len as usize;
606            if len <= chunk.len() {
607                buf.extend_from_slice(&chunk[..len]);
608            } else {
609                buf.extend_from_slice(&chunk);
610            }
611        }
612        Ok(buf)
613    }
614
615    fn read_storage_header(&self) -> FsResult<StorageHeader> {
616        let block = self.store.read_block(BLOCK_STORAGE_HEADER)?;
617        if block.len() < 4 {
618            return Err(FsError::InvalidSuperblock);
619        }
620        let len = u32::from_le_bytes([block[0], block[1], block[2], block[3]]) as usize;
621        if len == 0 || 4 + len > block.len() {
622            return Err(FsError::InvalidSuperblock);
623        }
624        self.codec
625            .deserialize_object::<StorageHeader>(&block[4..4 + len])
626    }
627
628    fn commit_superblock(&mut self, sb: Superblock) -> FsResult<()> {
629        self.txn.commit(
630            self.store.as_ref(),
631            self.crypto.as_ref(),
632            &self.codec,
633            &self.allocator,
634            &sb,
635        )?;
636        self.superblock = Some(sb);
637        Ok(())
638    }
639
640    /// Walk the metadata tree from the superblock and mark all referenced blocks
641    /// as allocated in the allocator. Used during open/mount.
642    fn rebuild_allocator(&mut self, sb: &Superblock) -> FsResult<()> {
643        // Mark superblock block.
644        // The superblock_ref's block was allocated by the transaction manager.
645        // We also need to mark root pointer blocks, but those are reserved (0,1,2).
646
647        // We need to find which block the superblock is stored in.
648        // The root pointer tells us.
649        let (rp, _) = TransactionManager::recover_latest(self.store.as_ref(), &self.codec)?
650            .ok_or(FsError::InvalidRootPointer)?;
651        self.allocator.mark_allocated(rp.superblock_ref.block_id)?;
652
653        // Walk root inode.
654        self.mark_inode_tree(sb.root_inode_ref.block_id)?;
655
656        // Set next_inode_id to be higher than any seen inode.
657        // (We updated it during the walk.)
658
659        Ok(())
660    }
661
662    fn mark_inode_tree(&mut self, inode_block: u64) -> FsResult<()> {
663        self.allocator.mark_allocated(inode_block)?;
664        let inode: Inode = self.read_obj(inode_block)?;
665
666        if inode.id >= self.next_inode_id {
667            self.next_inode_id = inode.id + 1;
668        }
669
670        match inode.kind {
671            InodeKind::Directory => {
672                if !inode.directory_page_ref.is_null() {
673                    self.allocator
674                        .mark_allocated(inode.directory_page_ref.block_id)?;
675                    let dir_page: DirectoryPage =
676                        self.read_obj(inode.directory_page_ref.block_id)?;
677                    for entry in &dir_page.entries {
678                        self.mark_inode_tree(entry.inode_ref.block_id)?;
679                    }
680                }
681            }
682            InodeKind::File => {
683                if !inode.extent_map_ref.is_null() {
684                    self.allocator
685                        .mark_allocated(inode.extent_map_ref.block_id)?;
686                    let extent_map: ExtentMap = self.read_obj(inode.extent_map_ref.block_id)?;
687                    for entry in &extent_map.entries {
688                        self.allocator.mark_allocated(entry.data_ref.block_id)?;
689                    }
690                }
691            }
692        }
693        Ok(())
694    }
695}
696
697/// Return type for directory listings (used by FFI and public API).
698#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
699pub struct DirListEntry {
700    pub name: String,
701    pub kind: InodeKind,
702    pub size: u64,
703    pub inode_id: InodeId,
704}