Skip to main content

mtp_mount/
inode.rs

1use std::collections::HashMap;
2use std::time::SystemTime;
3
4use mtp_rs::{ObjectHandle, StorageId};
5
6/// FUSE root inode number.
7pub const FUSE_ROOT_INODE: u64 = 1;
8
9/// What kind of entry an inode represents.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum InodeKind {
12    Root,
13    Storage { storage_id: StorageId },
14    Directory { handle: ObjectHandle },
15    File { handle: ObjectHandle },
16}
17
18/// Metadata cached for a single inode.
19#[derive(Debug, Clone)]
20pub struct InodeEntry {
21    pub inode: u64,
22    pub parent: u64,
23    pub name: String,
24    pub kind: InodeKind,
25    pub size: u64,
26    pub mtime: SystemTime,
27    pub atime: SystemTime,
28    /// Link generation the handle in `kind` was resolved against. MTP handles
29    /// are session-scoped, so a handle from an older generation is a stale
30    /// token that has to be re-resolved by path before it's used again.
31    pub generation: u64,
32}
33
34impl InodeEntry {
35    pub fn is_dir(&self) -> bool {
36        matches!(
37            self.kind,
38            InodeKind::Root | InodeKind::Storage { .. } | InodeKind::Directory { .. }
39        )
40    }
41}
42
43/// Bidirectional mapping between FUSE inodes and MTP objects, with cached metadata.
44#[derive(Debug)]
45pub struct InodeTable {
46    entries: HashMap<u64, InodeEntry>,
47    /// (parent_inode, child_name) -> child_inode
48    name_index: HashMap<(u64, String), u64>,
49    /// parent_inode -> list of child inodes
50    children_index: HashMap<u64, Vec<u64>>,
51    next_inode: u64,
52    /// Bumped on every reconnect; see [`InodeEntry::generation`].
53    generation: u64,
54}
55
56/// One child as the device reports it, for [`InodeTable::sync_children`].
57#[derive(Debug, Clone)]
58pub struct ChildInfo {
59    pub handle: ObjectHandle,
60    pub name: String,
61    pub is_dir: bool,
62    pub size: u64,
63    pub mtime: SystemTime,
64}
65
66impl Default for InodeTable {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl InodeTable {
73    /// Creates a new table with only the root inode (inode 1).
74    pub fn new() -> Self {
75        let root = InodeEntry {
76            inode: FUSE_ROOT_INODE,
77            parent: FUSE_ROOT_INODE,
78            name: String::new(),
79            kind: InodeKind::Root,
80            size: 0,
81            mtime: SystemTime::UNIX_EPOCH,
82            atime: SystemTime::UNIX_EPOCH,
83            generation: 0,
84        };
85        let mut entries = HashMap::new();
86        entries.insert(FUSE_ROOT_INODE, root);
87
88        Self {
89            entries,
90            name_index: HashMap::new(),
91            children_index: HashMap::new(),
92            next_inode: 2,
93            generation: 0,
94        }
95    }
96
97    /// Marks every cached object handle as stale, without touching inode
98    /// numbers, names, or the tree shape. Called after a reconnect: the kernel
99    /// and any open file descriptor keep referring to the same inodes, while
100    /// the handles behind them get re-resolved by path on next use.
101    pub fn bump_generation(&mut self) {
102        self.generation += 1;
103    }
104
105    /// Whether this inode's handle was resolved against the current session.
106    /// Storage and root entries carry no session-scoped handle, so they're
107    /// always fresh (their storage IDs are re-mapped eagerly on reconnect).
108    pub fn is_fresh(&self, inode: u64) -> bool {
109        match self.entries.get(&inode) {
110            Some(entry) => match entry.kind {
111                InodeKind::Root | InodeKind::Storage { .. } => true,
112                InodeKind::Directory { .. } | InodeKind::File { .. } => {
113                    entry.generation == self.generation
114                }
115            },
116            None => false,
117        }
118    }
119
120    /// Points an inode at a freshly resolved handle, keeping its inode number.
121    pub fn set_handle(&mut self, inode: u64, handle: ObjectHandle) {
122        let generation = self.generation;
123        let Some(entry) = self.entries.get_mut(&inode) else {
124            return;
125        };
126        entry.kind = match entry.kind {
127            InodeKind::Directory { .. } => InodeKind::Directory { handle },
128            InodeKind::File { .. } => InodeKind::File { handle },
129            _ => return,
130        };
131        entry.generation = generation;
132    }
133
134    /// Re-points a storage inode at a storage ID from the current session.
135    pub fn set_storage_id(&mut self, inode: u64, storage_id: StorageId) {
136        if let Some(entry) = self.entries.get_mut(&inode) {
137            if matches!(entry.kind, InodeKind::Storage { .. }) {
138                entry.kind = InodeKind::Storage { storage_id };
139            }
140        }
141    }
142
143    fn alloc_inode(&mut self) -> u64 {
144        let ino = self.next_inode;
145        self.next_inode += 1;
146        ino
147    }
148
149    fn insert(&mut self, entry: InodeEntry) -> u64 {
150        let ino = entry.inode;
151        let parent = entry.parent;
152        let name = entry.name.clone();
153
154        self.entries.insert(ino, entry);
155        self.name_index.insert((parent, name), ino);
156        self.children_index.entry(parent).or_default().push(ino);
157        ino
158    }
159
160    /// Adds a storage as a child of root. Returns the new inode number.
161    pub fn add_storage(&mut self, storage_id: StorageId, name: String) -> u64 {
162        let ino = self.alloc_inode();
163        let now = SystemTime::now();
164        self.insert(InodeEntry {
165            inode: ino,
166            parent: FUSE_ROOT_INODE,
167            name,
168            kind: InodeKind::Storage { storage_id },
169            size: 0,
170            mtime: now,
171            atime: now,
172            generation: self.generation,
173        })
174    }
175
176    /// Adds a file or directory under the given parent. Returns the new inode number.
177    pub fn add_object(
178        &mut self,
179        parent_inode: u64,
180        handle: ObjectHandle,
181        name: String,
182        is_dir: bool,
183        size: u64,
184        mtime: SystemTime,
185    ) -> u64 {
186        let ino = self.alloc_inode();
187        let kind = if is_dir {
188            InodeKind::Directory { handle }
189        } else {
190            InodeKind::File { handle }
191        };
192        self.insert(InodeEntry {
193            inode: ino,
194            parent: parent_inode,
195            name,
196            kind,
197            size,
198            mtime,
199            atime: mtime,
200            generation: self.generation,
201        })
202    }
203
204    /// Replaces a directory's children with what the device just reported,
205    /// **reusing the inode number** of every child that's still there under the
206    /// same name and kind.
207    ///
208    /// Inode numbers must survive a re-listing: the kernel caches them and open
209    /// file descriptors refer to them, so handing out a fresh number for a file
210    /// that didn't go anywhere breaks reads on an already-open fd. Only the
211    /// handle, size, and mtime are refreshed in place. Children the device no
212    /// longer reports are removed.
213    pub fn sync_children(&mut self, parent_inode: u64, children: &[ChildInfo]) {
214        let generation = self.generation;
215
216        for child in children {
217            match self.lookup(parent_inode, &child.name) {
218                Some(ino)
219                    if self
220                        .entries
221                        .get(&ino)
222                        .is_some_and(|e| e.is_dir() == child.is_dir) =>
223                {
224                    let entry = self.entries.get_mut(&ino).expect("looked up above");
225                    entry.kind = if child.is_dir {
226                        InodeKind::Directory {
227                            handle: child.handle,
228                        }
229                    } else {
230                        InodeKind::File {
231                            handle: child.handle,
232                        }
233                    };
234                    entry.size = child.size;
235                    entry.mtime = child.mtime;
236                    entry.generation = generation;
237                }
238                // A name that flipped between file and directory is a different
239                // object; drop the old inode and allocate a new one.
240                Some(ino) => {
241                    self.remove(ino);
242                    self.add_object(
243                        parent_inode,
244                        child.handle,
245                        child.name.clone(),
246                        child.is_dir,
247                        child.size,
248                        child.mtime,
249                    );
250                }
251                None => {
252                    self.add_object(
253                        parent_inode,
254                        child.handle,
255                        child.name.clone(),
256                        child.is_dir,
257                        child.size,
258                        child.mtime,
259                    );
260                }
261            }
262        }
263
264        let gone: Vec<u64> = self
265            .children(parent_inode)
266            .into_iter()
267            .filter(|ino| {
268                self.entries
269                    .get(ino)
270                    .is_some_and(|e| !children.iter().any(|c| c.name == e.name))
271            })
272            .collect();
273        for ino in gone {
274            self.remove(ino);
275        }
276    }
277
278    /// Looks up an entry by inode number.
279    pub fn get(&self, inode: u64) -> Option<&InodeEntry> {
280        self.entries.get(&inode)
281    }
282
283    /// Mutable lookup by inode number.
284    pub fn get_mut(&mut self, inode: u64) -> Option<&mut InodeEntry> {
285        self.entries.get_mut(&inode)
286    }
287
288    /// Finds a child inode by parent inode and name.
289    pub fn lookup(&self, parent_inode: u64, name: &str) -> Option<u64> {
290        self.name_index
291            .get(&(parent_inode, name.to_string()))
292            .copied()
293    }
294
295    /// Returns the inodes of all children of the given parent.
296    pub fn children(&self, parent_inode: u64) -> Vec<u64> {
297        self.children_index
298            .get(&parent_inode)
299            .cloned()
300            .unwrap_or_default()
301    }
302
303    /// Removes an entry and its index entries. Does not remove descendants.
304    pub fn remove(&mut self, inode: u64) -> Option<InodeEntry> {
305        let entry = self.entries.remove(&inode)?;
306        self.name_index.remove(&(entry.parent, entry.name.clone()));
307        if let Some(siblings) = self.children_index.get_mut(&entry.parent) {
308            siblings.retain(|&i| i != inode);
309        }
310        // Also remove any children index for this inode (but not the children themselves).
311        self.children_index.remove(&inode);
312        Some(entry)
313    }
314
315    /// Updates an entry's parent and name (for rename/move operations).
316    pub fn rename(&mut self, inode: u64, new_parent: u64, new_name: String) {
317        let Some(entry) = self.entries.get_mut(&inode) else {
318            return;
319        };
320        let old_parent = entry.parent;
321        let old_name = entry.name.clone();
322
323        // Update the entry itself.
324        entry.parent = new_parent;
325        entry.name = new_name.clone();
326
327        // Update name index.
328        self.name_index.remove(&(old_parent, old_name));
329        self.name_index.insert((new_parent, new_name), inode);
330
331        // Update children index.
332        if let Some(siblings) = self.children_index.get_mut(&old_parent) {
333            siblings.retain(|&i| i != inode);
334        }
335        self.children_index
336            .entry(new_parent)
337            .or_default()
338            .push(inode);
339    }
340
341    /// Finds the parent inode of an entry identified by its MTP object handle.
342    /// Returns `None` if the handle is not in the table.
343    pub fn find_parent_by_handle(&self, handle: ObjectHandle) -> Option<u64> {
344        self.entries.values().find_map(|e| match &e.kind {
345            InodeKind::File { handle: h } | InodeKind::Directory { handle: h } if *h == handle => {
346                Some(e.parent)
347            }
348            _ => None,
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_new_has_root() {
359        let table = InodeTable::new();
360        let root = table.get(FUSE_ROOT_INODE).expect("root must exist");
361        assert_eq!(root.inode, FUSE_ROOT_INODE);
362        assert_eq!(root.kind, InodeKind::Root);
363        assert!(root.is_dir());
364    }
365
366    #[test]
367    fn test_add_storage() {
368        let mut table = InodeTable::new();
369        let ino = table.add_storage(StorageId(1), "Internal".into());
370        assert_eq!(ino, 2);
371
372        let entry = table.get(ino).unwrap();
373        assert_eq!(entry.name, "Internal");
374        assert_eq!(
375            entry.kind,
376            InodeKind::Storage {
377                storage_id: StorageId(1)
378            }
379        );
380        assert_eq!(entry.parent, FUSE_ROOT_INODE);
381        assert!(entry.is_dir());
382    }
383
384    #[test]
385    fn test_add_object_file() {
386        let mut table = InodeTable::new();
387        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
388        let mtime = SystemTime::UNIX_EPOCH;
389
390        let file_ino = table.add_object(
391            storage_ino,
392            ObjectHandle(100),
393            "photo.jpg".into(),
394            false,
395            4096,
396            mtime,
397        );
398
399        let entry = table.get(file_ino).unwrap();
400        assert_eq!(entry.name, "photo.jpg");
401        assert_eq!(
402            entry.kind,
403            InodeKind::File {
404                handle: ObjectHandle(100)
405            }
406        );
407        assert_eq!(entry.size, 4096);
408        assert_eq!(entry.parent, storage_ino);
409        assert!(!entry.is_dir());
410    }
411
412    #[test]
413    fn test_add_object_directory() {
414        let mut table = InodeTable::new();
415        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
416        let mtime = SystemTime::UNIX_EPOCH;
417
418        let dir_ino = table.add_object(
419            storage_ino,
420            ObjectHandle(200),
421            "DCIM".into(),
422            true,
423            0,
424            mtime,
425        );
426
427        let entry = table.get(dir_ino).unwrap();
428        assert_eq!(
429            entry.kind,
430            InodeKind::Directory {
431                handle: ObjectHandle(200)
432            }
433        );
434        assert!(entry.is_dir());
435    }
436
437    #[test]
438    fn test_lookup_by_name() {
439        let mut table = InodeTable::new();
440        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
441        let mtime = SystemTime::UNIX_EPOCH;
442        let file_ino = table.add_object(
443            storage_ino,
444            ObjectHandle(100),
445            "photo.jpg".into(),
446            false,
447            1024,
448            mtime,
449        );
450
451        assert_eq!(table.lookup(storage_ino, "photo.jpg"), Some(file_ino));
452        assert_eq!(table.lookup(FUSE_ROOT_INODE, "Internal"), Some(storage_ino));
453    }
454
455    #[test]
456    fn test_lookup_nonexistent() {
457        let table = InodeTable::new();
458        assert_eq!(table.lookup(FUSE_ROOT_INODE, "nope"), None);
459        assert!(table.get(999).is_none());
460    }
461
462    #[test]
463    fn test_children() {
464        let mut table = InodeTable::new();
465        let s1 = table.add_storage(StorageId(1), "Internal".into());
466        let s2 = table.add_storage(StorageId(2), "SD Card".into());
467
468        let root_children = table.children(FUSE_ROOT_INODE);
469        assert_eq!(root_children, vec![s1, s2]);
470
471        let mtime = SystemTime::UNIX_EPOCH;
472        let f1 = table.add_object(s1, ObjectHandle(10), "a.txt".into(), false, 100, mtime);
473        let f2 = table.add_object(s1, ObjectHandle(11), "b.txt".into(), false, 200, mtime);
474
475        let storage_children = table.children(s1);
476        assert_eq!(storage_children, vec![f1, f2]);
477
478        assert!(table.children(s2).is_empty());
479    }
480
481    #[test]
482    fn test_remove() {
483        let mut table = InodeTable::new();
484        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
485        let mtime = SystemTime::UNIX_EPOCH;
486        let file_ino = table.add_object(
487            storage_ino,
488            ObjectHandle(100),
489            "photo.jpg".into(),
490            false,
491            1024,
492            mtime,
493        );
494
495        let removed = table.remove(file_ino).expect("should remove");
496        assert_eq!(removed.name, "photo.jpg");
497        assert!(table.get(file_ino).is_none());
498        assert_eq!(table.lookup(storage_ino, "photo.jpg"), None);
499        assert!(table.children(storage_ino).is_empty());
500    }
501
502    #[test]
503    fn test_rename() {
504        let mut table = InodeTable::new();
505        let s1 = table.add_storage(StorageId(1), "Internal".into());
506        let mtime = SystemTime::UNIX_EPOCH;
507        let dir_ino = table.add_object(s1, ObjectHandle(200), "DCIM".into(), true, 0, mtime);
508        let file_ino = table.add_object(s1, ObjectHandle(100), "old.txt".into(), false, 512, mtime);
509
510        // Move file from storage root into DCIM and rename it.
511        table.rename(file_ino, dir_ino, "new.txt".into());
512
513        assert_eq!(table.lookup(s1, "old.txt"), None);
514        assert_eq!(table.lookup(dir_ino, "new.txt"), Some(file_ino));
515
516        let entry = table.get(file_ino).unwrap();
517        assert_eq!(entry.parent, dir_ino);
518        assert_eq!(entry.name, "new.txt");
519
520        assert!(!table.children(s1).contains(&file_ino));
521        assert!(table.children(dir_ino).contains(&file_ino));
522    }
523
524    fn child(handle: u64, name: &str, is_dir: bool, size: u64) -> ChildInfo {
525        ChildInfo {
526            handle: ObjectHandle(handle),
527            name: name.into(),
528            is_dir,
529            size,
530            mtime: SystemTime::UNIX_EPOCH,
531        }
532    }
533
534    #[test]
535    fn test_sync_children_keeps_inode_numbers_stable() {
536        let mut table = InodeTable::new();
537        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
538        table.sync_children(
539            storage_ino,
540            &[
541                child(10, "a.txt", false, 100),
542                child(11, "b.txt", false, 200),
543            ],
544        );
545        let a = table.lookup(storage_ino, "a.txt").unwrap();
546
547        // Re-listing the same directory with new handles (a fresh session) must
548        // keep the inode number: open fds and the kernel cache depend on it.
549        table.sync_children(
550            storage_ino,
551            &[
552                child(77, "a.txt", false, 150),
553                child(78, "b.txt", false, 200),
554            ],
555        );
556
557        assert_eq!(table.lookup(storage_ino, "a.txt"), Some(a));
558        let entry = table.get(a).unwrap();
559        assert_eq!(
560            entry.kind,
561            InodeKind::File {
562                handle: ObjectHandle(77)
563            }
564        );
565        assert_eq!(entry.size, 150);
566    }
567
568    #[test]
569    fn test_sync_children_adds_and_removes() {
570        let mut table = InodeTable::new();
571        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
572        table.sync_children(
573            storage_ino,
574            &[
575                child(10, "stays.txt", false, 1),
576                child(11, "goes.txt", false, 2),
577            ],
578        );
579        let goes = table.lookup(storage_ino, "goes.txt").unwrap();
580
581        table.sync_children(
582            storage_ino,
583            &[
584                child(10, "stays.txt", false, 1),
585                child(12, "new.txt", false, 3),
586            ],
587        );
588
589        assert!(table.get(goes).is_none());
590        assert_eq!(table.lookup(storage_ino, "goes.txt"), None);
591        assert!(table.lookup(storage_ino, "new.txt").is_some());
592        assert_eq!(table.children(storage_ino).len(), 2);
593    }
594
595    #[test]
596    fn test_sync_children_replaces_when_kind_flips() {
597        let mut table = InodeTable::new();
598        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
599        table.sync_children(storage_ino, &[child(10, "thing", false, 5)]);
600        let file_ino = table.lookup(storage_ino, "thing").unwrap();
601
602        table.sync_children(storage_ino, &[child(11, "thing", true, 0)]);
603
604        let dir_ino = table.lookup(storage_ino, "thing").unwrap();
605        assert_ne!(
606            dir_ino, file_ino,
607            "a file replaced by a dir is a new object"
608        );
609        assert!(table.get(dir_ino).unwrap().is_dir());
610    }
611
612    #[test]
613    fn test_generation_marks_handles_stale() {
614        let mut table = InodeTable::new();
615        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
616        table.sync_children(storage_ino, &[child(10, "a.txt", false, 100)]);
617        let a = table.lookup(storage_ino, "a.txt").unwrap();
618        assert!(table.is_fresh(a));
619
620        table.bump_generation();
621
622        assert!(!table.is_fresh(a), "handles from an old session are stale");
623        assert!(
624            table.is_fresh(storage_ino),
625            "storage inodes carry no session-scoped handle"
626        );
627        assert!(table.is_fresh(FUSE_ROOT_INODE));
628
629        table.set_handle(a, ObjectHandle(999));
630        assert!(table.is_fresh(a));
631        assert_eq!(
632            table.get(a).unwrap().kind,
633            InodeKind::File {
634                handle: ObjectHandle(999)
635            }
636        );
637    }
638
639    #[test]
640    fn test_set_storage_id_remaps_in_place() {
641        let mut table = InodeTable::new();
642        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
643        table.set_storage_id(storage_ino, StorageId(42));
644        assert_eq!(
645            table.get(storage_ino).unwrap().kind,
646            InodeKind::Storage {
647                storage_id: StorageId(42)
648            }
649        );
650    }
651
652    #[test]
653    fn test_inode_uniqueness() {
654        let mut table = InodeTable::new();
655        let mtime = SystemTime::UNIX_EPOCH;
656        let mut inodes = vec![FUSE_ROOT_INODE];
657        inodes.push(table.add_storage(StorageId(1), "A".into()));
658        inodes.push(table.add_storage(StorageId(2), "B".into()));
659        inodes.push(table.add_object(inodes[1], ObjectHandle(1), "x".into(), false, 0, mtime));
660        inodes.push(table.add_object(inodes[1], ObjectHandle(2), "y".into(), true, 0, mtime));
661
662        let unique: std::collections::HashSet<u64> = inodes.iter().copied().collect();
663        assert_eq!(unique.len(), inodes.len(), "all inodes must be unique");
664    }
665
666    #[test]
667    fn test_nested_directories() {
668        let mut table = InodeTable::new();
669        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
670        let mtime = SystemTime::UNIX_EPOCH;
671
672        let dcim = table.add_object(storage_ino, ObjectHandle(1), "DCIM".into(), true, 0, mtime);
673        let camera = table.add_object(dcim, ObjectHandle(2), "Camera".into(), true, 0, mtime);
674        let photo = table.add_object(
675            camera,
676            ObjectHandle(3),
677            "IMG_001.jpg".into(),
678            false,
679            8192,
680            mtime,
681        );
682
683        // Verify the chain: root -> storage -> DCIM -> Camera -> photo
684        assert!(table.children(FUSE_ROOT_INODE).contains(&storage_ino));
685        assert!(table.children(storage_ino).contains(&dcim));
686        assert!(table.children(dcim).contains(&camera));
687        assert!(table.children(camera).contains(&photo));
688
689        // Lookup through the chain.
690        assert_eq!(table.lookup(FUSE_ROOT_INODE, "Internal"), Some(storage_ino));
691        assert_eq!(table.lookup(storage_ino, "DCIM"), Some(dcim));
692        assert_eq!(table.lookup(dcim, "Camera"), Some(camera));
693        assert_eq!(table.lookup(camera, "IMG_001.jpg"), Some(photo));
694
695        let photo_entry = table.get(photo).unwrap();
696        assert_eq!(photo_entry.parent, camera);
697        assert_eq!(photo_entry.size, 8192);
698    }
699
700    #[test]
701    fn test_get_mut() {
702        let mut table = InodeTable::new();
703        let storage_ino = table.add_storage(StorageId(1), "Internal".into());
704        let mtime = SystemTime::UNIX_EPOCH;
705        let file_ino = table.add_object(
706            storage_ino,
707            ObjectHandle(1),
708            "f.txt".into(),
709            false,
710            100,
711            mtime,
712        );
713
714        table.get_mut(file_ino).unwrap().size = 999;
715        assert_eq!(table.get(file_ino).unwrap().size, 999);
716    }
717}