devices/virtio/fs/
allowlist.rs

1// Copyright 2026 The ChromiumOS Authors
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use std::collections::HashMap;
6use std::collections::HashSet;
7use std::ffi::OsStr;
8use std::ffi::OsString;
9use std::path::Path;
10use std::path::PathBuf;
11
12/// Normalizes a path lexically by resolving `..` and `.` components.
13/// Returns `Some(PathBuf)` containing an absolute path starting with `/`,
14/// or `None` if the path is invalid (e.g., attempts to traverse above the root).
15// TODO: Replace with `std::path::Path::normalize_lexically` once it is stabilized.
16// Note the behavioral differences compared to `std::path::Path::normalize_lexically`:
17// 1. The standard library's `normalize_lexically` returns a `Result` and errors on `..` components
18//    that traverse above the root or starting point (e.g., `/../a` or `a/../../b`), which matches
19//    this function's behavior of returning `None`.
20// 2. This function always returns an absolute path starting with `/`, whereas the standard
21//    library's `normalize_lexically` preserves relative paths.
22fn normalize_lexically(path: &Path) -> Option<PathBuf> {
23    let mut components = Vec::new();
24    for component in path.components() {
25        match component {
26            std::path::Component::RootDir => {
27                components.clear();
28            }
29            std::path::Component::CurDir => {}
30            std::path::Component::ParentDir => {
31                // Error if attempting to traverse above the root directory.
32                components.pop()?;
33            }
34            std::path::Component::Normal(c) => {
35                components.push(c);
36            }
37            _ => {}
38        }
39    }
40    let mut normalized = PathBuf::from("/");
41    for c in components {
42        normalized.push(c);
43    }
44    Some(normalized)
45}
46
47/// Represents the access level for a path in the allowlist.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
49pub enum AccessLevel {
50    /// No access allowed.
51    None,
52    /// Directory traversal (lookup) only access.
53    /// Automatically granted to ancestor directories of allowed paths to act as a traversable
54    /// pathway. This level is only granted automatically and cannot be configured manually.
55    Traverse,
56    /// Full read and write access.
57    /// Granted to explicitly allowed paths and inherited by all their descendants.
58    Full,
59}
60
61/// Represents a pre-calculated filter for directory entry validation.
62#[derive(Debug, Clone)]
63pub enum ReadDirFilter {
64    /// Allows all directory entries (parent directory has Full access level).
65    AllowAll,
66    /// Allows only the specified entry names (parent directory has Traverse access level).
67    AllowOnly(HashSet<OsString>),
68    /// Denies all directory entries (parent directory is not accessible).
69    DenyAll,
70}
71
72#[derive(Debug, Clone)]
73struct TrieNode {
74    access_level: AccessLevel,
75    children: HashMap<OsString, TrieNode>,
76    active_children_count: usize,
77}
78
79impl Default for TrieNode {
80    fn default() -> Self {
81        Self {
82            access_level: AccessLevel::None,
83            children: HashMap::new(),
84            active_children_count: 0,
85        }
86    }
87}
88
89impl TrieNode {
90    /// Returns true if this node is active (has an access level > None or has active children).
91    fn is_active(&self) -> bool {
92        self.access_level > AccessLevel::None || self.active_children_count > 0
93    }
94
95    /// Returns true if any descendant of this node has an active access level.
96    fn has_active_descendants(&self) -> bool {
97        self.active_children_count > 0
98    }
99}
100
101/// A hierarchical path allowlist that restricts file system access using a prefix tree (Trie).
102///
103/// The allowlist provides a high-performance, zero-overhead mechanism (when unused) to enforce
104/// path-based access boundaries for FUSE/virtiofs devices.
105///
106/// # Public API Semantics
107///
108/// Access checks are divided into two distinct operations:
109/// * **`is_accessible(path)`**: Checks if a path can be looked up or read (e.g., for `lookup`,
110///   `readdir`, `open`).
111///   - Returns `true` if the path is explicitly allowed, is a descendant of an allowed path, or is
112///     an ancestor directory needed to reach an allowed path.
113/// * **`is_writable(path)`**: Checks if a path can be modified (e.g., for `mkdir`, `create`,
114///   `unlink`, `rename`).
115///   - Returns `true` ONLY if the path is explicitly allowed or is a descendant of an allowed path.
116///     **Ancestor directories are never writable.**
117///
118/// # Under the Hood: Access Levels & Inheritance
119///
120/// Internally, each path in the Trie is mapped to one of the following **`AccessLevel`**s:
121/// * **`None` (Blocked)**: Complete restriction.
122/// * **`Traverse` (Traversal Only - Non-inheritable)**: Granted to ancestor directories. It serves
123///   strictly as a traversable pathway to reach allowed paths. Sibling paths under a `Traverse`
124///   directory remain blocked.
125/// * **`Full` (Full Access - Inheritable)**: Granted to explicitly allowed paths. This level is
126///   automatically propagated to all descendant paths (e.g., allowing `/a/b` automatically grants
127///   `Full` access to `/a/b/c/**`).
128///
129/// # Illustrative Scenarios
130///
131/// ## Scenario 1: When `/a/b` and `/a/b/c` are added to allowlist
132///
133/// Accessible:
134///
135/// - `/`, `/a`, `/a/b/**` (traversable down to `/a/b` and all its descendants)
136///
137/// Writable:
138///
139/// - `/a/b/**` (full write access inside `/a/b` and all its descendants)
140///
141/// ## Scenario 2: When access to `/a/b` is revoked (while keeping `/a/b/c` allowed)
142///
143/// To keep `/a/b/c` accessible, `/a/b` is automatically demoted to `Traverse` (Read-only traversal
144/// pathway) rather than being blocked entirely:
145///
146/// Accessible:
147///
148/// - `/`, `/a`, `/a/b`, `/a/b/c/**` (traversal is allowed through `/a/b`, but sibling `/a/b/d` is
149///   now blocked)
150///
151/// Writable:
152///
153/// - `/a/b/c/**` (write access is strictly restricted to the remaining allowed subtree)
154#[derive(Debug, Clone, Default)]
155pub struct PathAllowlist {
156    root: TrieNode,
157}
158
159impl PathAllowlist {
160    /// Creates a new `PathAllowlist`.
161    pub fn new() -> Self {
162        Self {
163            root: TrieNode::default(),
164        }
165    }
166
167    /// Parses a normalized path into its components, ignoring non-normal components.
168    fn parse_components(path: &Path) -> Vec<&OsStr> {
169        path.components()
170            .filter_map(|c| match c {
171                std::path::Component::Normal(s) => Some(s),
172                _ => None,
173            })
174            .collect()
175    }
176
177    /// Adds a path to the allowed list. The path will be normalized before being added.
178    /// Returns true if the path was valid and successfully added.
179    /// Returns false if the path was invalid (e.g. traversed above root).
180    pub fn add_path<P: AsRef<Path>>(&mut self, path: P) -> bool {
181        let normalized = match normalize_lexically(path.as_ref()) {
182            Some(p) => p,
183            None => return false,
184        };
185        let components = Self::parse_components(&normalized);
186
187        // Adds a path component recursively.
188        // Returns true if this node's active state changed from inactive to active.
189        fn add_rec(node: &mut TrieNode, components: &[&OsStr]) -> bool {
190            let was_active = node.is_active();
191
192            if components.is_empty() {
193                node.access_level = AccessLevel::Full;
194                return !was_active && node.is_active();
195            }
196
197            let first = components[0];
198            if node.access_level == AccessLevel::None {
199                node.access_level = AccessLevel::Traverse;
200            }
201
202            let child = node.children.entry(first.to_os_string()).or_default();
203            let child_active_changed = add_rec(child, &components[1..]);
204
205            if child_active_changed {
206                node.active_children_count += 1;
207            }
208
209            !was_active && node.is_active()
210        }
211
212        add_rec(&mut self.root, &components);
213        true
214    }
215
216    /// Removes a path from the allowed list. The path will be normalized before removal.
217    /// Returns true if the path was explicitly allowed and successfully removed (or demoted).
218    /// Returns false if the path was not explicitly allowed.
219    pub fn remove_path<P: AsRef<Path>>(&mut self, path: P) -> bool {
220        let normalized = match normalize_lexically(path.as_ref()) {
221            Some(p) => p,
222            None => return false,
223        };
224        let components = Self::parse_components(&normalized);
225
226        // Removes a path component recursively.
227        // Returns (removed, became_inactive):
228        // - removed: true if the path was explicitly allowed and successfully removed/demoted.
229        // - became_inactive: true if this node's active state changed from active to inactive.
230        fn remove_rec(node: &mut TrieNode, components: &[&OsStr]) -> (bool, bool) {
231            let was_active = node.is_active();
232
233            if components.is_empty() {
234                if node.access_level != AccessLevel::Full {
235                    return (false, false);
236                }
237
238                if node.has_active_descendants() {
239                    // If it has active descendants, demote it to Traverse to keep it as an
240                    // ancestor.
241                    node.access_level = AccessLevel::Traverse;
242                } else {
243                    node.access_level = AccessLevel::None;
244                }
245                let became_inactive = was_active && !node.is_active();
246                return (true, became_inactive);
247            }
248
249            let first = components[0];
250            let mut removed = false;
251
252            if let Some(child) = node.children.get_mut(first) {
253                let (child_removed, child_became_inactive) = remove_rec(child, &components[1..]);
254                removed = child_removed;
255
256                if child_became_inactive {
257                    node.active_children_count -= 1;
258                }
259
260                // Clean up the child node if it is no longer active.
261                if !child.is_active() {
262                    node.children.remove(first);
263                }
264            }
265
266            // Demote this ancestor node if it no longer has any active descendants.
267            if node.access_level == AccessLevel::Traverse && !node.has_active_descendants() {
268                node.access_level = AccessLevel::None;
269            }
270
271            let became_inactive = was_active && !node.is_active();
272            (removed, became_inactive)
273        }
274
275        let (removed, _) = remove_rec(&mut self.root, &components);
276        removed
277    }
278
279    /// Resolves the effective access level for a given path by traversing the Trie.
280    fn get_access_level(&self, path: &Path) -> AccessLevel {
281        let normalized = match normalize_lexically(path) {
282            Some(p) => p,
283            None => return AccessLevel::None,
284        };
285        let components = Self::parse_components(&normalized);
286
287        let mut current = &self.root;
288        if current.access_level == AccessLevel::Full {
289            return AccessLevel::Full;
290        }
291
292        for comp in components {
293            if let Some(next) = current.children.get(comp) {
294                current = next;
295                if current.access_level == AccessLevel::Full {
296                    return AccessLevel::Full;
297                }
298            } else {
299                return AccessLevel::None;
300            }
301        }
302
303        current.access_level
304    }
305
306    /// Checks if a path is accessible (read/lookup).
307    ///
308    /// A path is accessible if it has at least `Traverse` access level.
309    pub fn is_accessible<P: AsRef<Path>>(&self, path: P) -> bool {
310        if !self.root.is_active() {
311            return false;
312        }
313        self.get_access_level(path.as_ref()) >= AccessLevel::Traverse
314    }
315
316    /// Checks if a path is allowed to be written to.
317    ///
318    /// A path is writable only if it has `Full` access level.
319    pub fn is_writable<P: AsRef<Path>>(&self, path: P) -> bool {
320        if !self.root.is_active() {
321            return false;
322        }
323        self.get_access_level(path.as_ref()) == AccessLevel::Full
324    }
325
326    /// Returns a `ReadDirFilter` for the given parent directory path.
327    ///
328    /// This pre-calculates the accessible entries within the directory, avoiding the need
329    /// to perform full path resolution and Trie traversal for each individual entry during
330    /// directory listing.
331    pub fn get_read_dir_filter<P: AsRef<Path>>(&self, parent_path: P) -> ReadDirFilter {
332        if !self.root.is_active() {
333            return ReadDirFilter::DenyAll;
334        }
335
336        let normalized = match normalize_lexically(parent_path.as_ref()) {
337            Some(p) => p,
338            None => return ReadDirFilter::DenyAll,
339        };
340        let components = Self::parse_components(&normalized);
341
342        let mut current = &self.root;
343        if current.access_level == AccessLevel::Full {
344            return ReadDirFilter::AllowAll;
345        }
346
347        for comp in components {
348            if let Some(next) = current.children.get(comp) {
349                current = next;
350                if current.access_level == AccessLevel::Full {
351                    return ReadDirFilter::AllowAll;
352                }
353            } else {
354                return ReadDirFilter::DenyAll;
355            }
356        }
357
358        match current.access_level {
359            AccessLevel::Full => ReadDirFilter::AllowAll,
360            AccessLevel::Traverse => {
361                let allowed_entries = current
362                    .children
363                    .iter()
364                    .filter(|(_, child)| child.is_active())
365                    .map(|(name, _)| name.clone())
366                    .collect::<HashSet<_>>();
367                ReadDirFilter::AllowOnly(allowed_entries)
368            }
369            AccessLevel::None => ReadDirFilter::DenyAll,
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_normalize_lexically() {
380        assert_eq!(
381            normalize_lexically(Path::new("/a/b/c")),
382            Some(PathBuf::from("/a/b/c"))
383        );
384        assert_eq!(
385            normalize_lexically(Path::new("/a/../b")),
386            Some(PathBuf::from("/b"))
387        );
388        assert_eq!(
389            normalize_lexically(Path::new("/a/./b")),
390            Some(PathBuf::from("/a/b"))
391        );
392        assert_eq!(
393            normalize_lexically(Path::new("/")),
394            Some(PathBuf::from("/"))
395        );
396        assert_eq!(normalize_lexically(Path::new("")), Some(PathBuf::from("/")));
397        assert_eq!(
398            normalize_lexically(Path::new("a/b")),
399            Some(PathBuf::from("/a/b"))
400        );
401        assert_eq!(
402            normalize_lexically(Path::new("/a/b/../c/./d")),
403            Some(PathBuf::from("/a/c/d"))
404        );
405
406        // Error cases (above root)
407        assert_eq!(normalize_lexically(Path::new("..")), None);
408        assert_eq!(normalize_lexically(Path::new("/..")), None);
409        assert_eq!(normalize_lexically(Path::new("/a/../..")), None);
410        assert_eq!(normalize_lexically(Path::new("/a/b/../../..")), None);
411    }
412
413    #[test]
414    fn test_path_allowlist_empty() {
415        let allowlist = PathAllowlist::new();
416        // When empty, everything should be blocked
417        assert!(!allowlist.is_accessible("/a/b"));
418        assert!(!allowlist.is_writable("/a/b"));
419    }
420
421    #[test]
422    fn test_path_allowlist_allowed_rules() {
423        let mut allowlist = PathAllowlist::new();
424        allowlist.add_path("/a/b");
425
426        // Exact match
427        assert!(allowlist.is_accessible("/a/b"));
428        // Child path (inherited Full)
429        assert!(allowlist.is_accessible("/a/b/c"));
430        assert!(allowlist.is_accessible("/a/b/c/d"));
431        // Ancestor path (explicit Traverse)
432        assert!(allowlist.is_accessible("/a"));
433        assert!(allowlist.is_accessible("/"));
434
435        // Unrelated path
436        assert!(!allowlist.is_accessible("/d"));
437        assert!(!allowlist.is_accessible("/a/c"));
438    }
439
440    #[test]
441    fn test_path_allowlist_writable_rules() {
442        let mut allowlist = PathAllowlist::new();
443        allowlist.add_path("/a/b");
444
445        // Exact match
446        assert!(allowlist.is_writable("/a/b"));
447        // Child path
448        assert!(allowlist.is_writable("/a/b/c"));
449
450        // Ancestor path (NOT writable, only allowed for lookup)
451        assert!(!allowlist.is_writable("/a"));
452        assert!(!allowlist.is_writable("/"));
453
454        // Unrelated path
455        assert!(!allowlist.is_writable("/d"));
456    }
457
458    #[test]
459    fn test_path_allowlist_multiple_paths() {
460        let mut allowlist = PathAllowlist::new();
461        allowlist.add_path("/a/b");
462        allowlist.add_path("/c/d");
463
464        assert!(allowlist.is_accessible("/a/b"));
465        assert!(allowlist.is_accessible("/c/d"));
466        assert!(allowlist.is_accessible("/a"));
467        assert!(allowlist.is_accessible("/c"));
468
469        assert!(!allowlist.is_accessible("/e"));
470    }
471
472    #[test]
473    fn test_path_allowlist_remove_path() {
474        let mut allowlist = PathAllowlist::new();
475        allowlist.add_path("/a/b");
476        assert!(allowlist.is_accessible("/a/b"));
477
478        assert!(allowlist.remove_path("/a/b"));
479        assert!(!allowlist.is_accessible("/a/b"));
480
481        // Removing again should fail
482        assert!(!allowlist.remove_path("/a/b"));
483    }
484
485    #[test]
486    fn test_path_allowlist_remove_parent_keeps_child() {
487        // Both parent and child are explicitly allowed. Removing the parent
488        // should demote the parent to Traverse, but the child remains Full (accessible & writable).
489        let mut allowlist = PathAllowlist::new();
490        allowlist.add_path("/a/b");
491        allowlist.add_path("/a/b/c");
492
493        assert!(allowlist.is_accessible("/a/b"));
494        assert!(allowlist.is_writable("/a/b"));
495        assert!(allowlist.is_accessible("/a/b/c"));
496        assert!(allowlist.is_writable("/a/b/c"));
497
498        assert!(allowlist.remove_path("/a/b"));
499
500        // Child remains fully accessible.
501        assert!(allowlist.is_accessible("/a/b/c"));
502        assert!(allowlist.is_writable("/a/b/c"));
503
504        // Parent is demoted to Traverse (accessible but not writable).
505        assert!(allowlist.is_accessible("/a/b"));
506        assert!(!allowlist.is_writable("/a/b"));
507
508        // Ancestors remain accessible.
509        assert!(allowlist.is_accessible("/a"));
510        assert!(allowlist.is_accessible("/"));
511
512        // Removing the demoted parent again should fail as it is no longer explicitly allowed.
513        assert!(!allowlist.remove_path("/a/b"));
514    }
515
516    #[test]
517    fn test_path_allowlist_remove_child_inherited() {
518        // Both parent and child are explicitly allowed. Removing the child
519        // should not block the child because it still inherits Full access from the parent.
520        let mut allowlist = PathAllowlist::new();
521        allowlist.add_path("/a/b");
522        allowlist.add_path("/a/b/c");
523
524        assert!(allowlist.remove_path("/a/b/c"));
525
526        // Parent remains fully accessible.
527        assert!(allowlist.is_accessible("/a/b"));
528        assert!(allowlist.is_writable("/a/b"));
529
530        // Child remains accessible due to inheritance from the parent.
531        assert!(allowlist.is_accessible("/a/b/c"));
532        assert!(allowlist.is_writable("/a/b/c"));
533
534        // Removing the child again should fail.
535        assert!(!allowlist.remove_path("/a/b/c"));
536    }
537
538    #[test]
539    fn test_path_allowlist_remove_one_of_multiple_children() {
540        // Two sibling paths are allowed under a common ancestor. Removing one sibling
541        // should block it, but the other sibling and the ancestor (Traverse) should remain.
542        let mut allowlist = PathAllowlist::new();
543        allowlist.add_path("/a/b/c");
544        allowlist.add_path("/a/b/d");
545
546        assert!(allowlist.is_accessible("/a/b/c"));
547        assert!(allowlist.is_writable("/a/b/c"));
548        assert!(allowlist.is_accessible("/a/b/d"));
549        assert!(allowlist.is_writable("/a/b/d"));
550        assert!(allowlist.is_accessible("/a/b"));
551        assert!(!allowlist.is_writable("/a/b"));
552
553        assert!(allowlist.remove_path("/a/b/c"));
554
555        // Sibling remains fully accessible.
556        assert!(allowlist.is_accessible("/a/b/d"));
557        assert!(allowlist.is_writable("/a/b/d"));
558
559        // Removed path is blocked.
560        assert!(!allowlist.is_accessible("/a/b/c"));
561        assert!(!allowlist.is_writable("/a/b/c"));
562
563        // Common ancestor remains Traverse.
564        assert!(allowlist.is_accessible("/a/b"));
565        assert!(!allowlist.is_writable("/a/b"));
566
567        // Removing the child again should fail.
568        assert!(!allowlist.remove_path("/a/b/c"));
569    }
570
571    #[test]
572    fn test_path_allowlist_remove_non_existent() {
573        // Removing a non-existent path should not affect existing paths and should return false.
574        let mut allowlist = PathAllowlist::new();
575        allowlist.add_path("/a/b");
576
577        assert!(!allowlist.remove_path("/a/c"));
578
579        assert!(allowlist.is_accessible("/a/b"));
580        assert!(allowlist.is_writable("/a/b"));
581    }
582
583    #[cfg(unix)]
584    #[test]
585    fn test_path_allowlist_non_utf8() {
586        use std::os::unix::ffi::OsStrExt;
587
588        let mut allowlist = PathAllowlist::new();
589        // Create paths with invalid UTF-8 bytes (e.g., 0xff and 0xfe)
590        let path_ff = OsStr::from_bytes(b"/a/b\xff");
591        let path_fe = OsStr::from_bytes(b"/a/b\xfe");
592
593        allowlist.add_path(Path::new(path_ff));
594
595        // Exact match for allowed non-UTF8 path should succeed
596        assert!(allowlist.is_accessible(Path::new(path_ff)));
597        assert!(allowlist.is_writable(Path::new(path_ff)));
598
599        // A different invalid UTF-8 path should be blocked (no false collision!)
600        assert!(!allowlist.is_accessible(Path::new(path_fe)));
601        assert!(!allowlist.is_writable(Path::new(path_fe)));
602    }
603
604    #[test]
605    fn test_path_allowlist_invalid_paths() {
606        let mut allowlist = PathAllowlist::new();
607
608        // Adding invalid path should be ignored and return false
609        assert!(!allowlist.add_path("/a/../.."));
610        assert!(!allowlist.is_accessible("/"));
611
612        // Removing invalid path should return false and not affect others
613        assert!(allowlist.add_path("/a"));
614        assert!(allowlist.is_accessible("/a"));
615        assert!(!allowlist.remove_path("/a/../.."));
616        assert!(allowlist.is_accessible("/a"));
617    }
618
619    #[test]
620    fn test_path_allowlist_get_read_dir_filter() {
621        let mut allowlist = PathAllowlist::new();
622
623        // Empty allowlist should return DenyAll for any path
624        assert!(matches!(
625            allowlist.get_read_dir_filter("/"),
626            ReadDirFilter::DenyAll
627        ));
628        assert!(matches!(
629            allowlist.get_read_dir_filter("/a"),
630            ReadDirFilter::DenyAll
631        ));
632
633        allowlist.add_path("/a/b");
634
635        // Root directory is Traverse, should only allow "a"
636        match allowlist.get_read_dir_filter("/") {
637            ReadDirFilter::AllowOnly(set) => {
638                assert_eq!(set.len(), 1);
639                assert!(set.contains(OsStr::new("a")));
640            }
641            _ => panic!("expected AllowOnly"),
642        }
643
644        // /a is Traverse, should only allow "b"
645        match allowlist.get_read_dir_filter("/a") {
646            ReadDirFilter::AllowOnly(set) => {
647                assert_eq!(set.len(), 1);
648                assert!(set.contains(OsStr::new("b")));
649            }
650            _ => panic!("expected AllowOnly"),
651        }
652
653        // /a/b is Full, should return AllowAll
654        assert!(matches!(
655            allowlist.get_read_dir_filter("/a/b"),
656            ReadDirFilter::AllowAll
657        ));
658
659        // Descendant of Full path should also return AllowAll
660        assert!(matches!(
661            allowlist.get_read_dir_filter("/a/b/c"),
662            ReadDirFilter::AllowAll
663        ));
664
665        // Test redundant nodes under Full path
666        let mut allowlist2 = PathAllowlist::new();
667        allowlist2.add_path("/a/b/c");
668        allowlist2.add_path("/a");
669        // /a is Full, so /a/b should be AllowAll even if 'b' exists as Traverse in Trie
670        assert!(matches!(
671            allowlist2.get_read_dir_filter("/a/b"),
672            ReadDirFilter::AllowAll
673        ));
674
675        // Unrelated path should return DenyAll
676        assert!(matches!(
677            allowlist.get_read_dir_filter("/d"),
678            ReadDirFilter::DenyAll
679        ));
680    }
681}