d_major/
vfs.rs

1/*
2 * Description: OS filesystem abstraction.
3 *
4 * Copyright (C) 2025 d@nny mc² <dmc2@hypnicjerk.ai>
5 * SPDX-License-Identifier: LGPL-3.0-or-later
6 *
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Lesser General Public License as published
9 * by the Free Software Foundation, either version 3 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 * GNU Lesser General Public License for more details.
16 *
17 * You should have received a copy of the GNU Lesser General Public License
18 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19 */
20
21//! OS filesystem abstraction.
22
23pub mod traits {
24  #[cfg(doc)]
25  use std::{
26    fs, io,
27    path::{Path, PathBuf},
28  };
29
30
31  #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
32  pub enum FileType {
33    File,
34    Dir,
35    Link,
36  }
37
38  pub trait Stat {
39    type Mode;
40    fn file_type(&self) -> Result<FileType, Self::Mode>;
41  }
42
43  pub trait DirectoryEntry {
44    type PathRef<'s>;
45    type OwnedPath;
46
47    fn name<'e>(&'e self) -> Self::PathRef<'e>;
48    fn owned_name(path: Self::PathRef<'_>) -> Self::OwnedPath;
49
50    /// This is different from the type used for [`Stat::Mode`], as POSIX directory entries do not
51    /// contain a full stat struct but instead a specific field with its own values to parse.
52    type EagerMode;
53
54    /// Some platforms have this in the directory entry, while some require an additional stat call.
55    fn eager_file_type(&self) -> Option<Result<FileType, Self::EagerMode>>;
56  }
57
58  pub trait DirectoryStream {
59    /// A single result from a directory stream, like [`fs::DirEntry`].
60    type DirEntry<'dir>: DirectoryEntry
61    where Self: 'dir;
62    /// Result type which is either [`io::Error`] or wraps it.
63    type Err;
64
65    fn read_dir<'dir>(&'dir mut self) -> Result<Option<Self::DirEntry<'dir>>, Self::Err>;
66  }
67
68  pub trait VFS {
69    /// Can be thought of as essentially a [`PathBuf`], but in practice forms a trie.
70    type Ctx<'vfs>
71    where Self: 'vfs;
72    /// Result type which is either [`io::Error`] or wraps it.
73    type Err;
74
75    /// Convertible from a null-terminated C string, especially
76    /// [`MeasuredNullTermStr`](crate::null_term_str::MeasuredNullTermStr).
77    type PathRef<'s>;
78    /// Convertible from an owned null-terminated C string, especially
79    /// [`NullTermString`](crate::null_term_str::NullTermString).
80    type OwnedPath;
81    fn path_ref<'s>(p: &'s Self::OwnedPath) -> Self::PathRef<'s>;
82
83    /// This will return a context representing the process's current working directory.
84    fn initial_context<'vfs>(&'vfs self) -> Result<Self::Ctx<'vfs>, Self::Err>;
85    fn join_context_dir<'vfs>(
86      &'vfs self,
87      ctx: Self::Ctx<'vfs>,
88      rel: Self::PathRef<'_>,
89    ) -> Self::Ctx<'vfs>;
90    fn join_context_link<'vfs>(
91      &'vfs self,
92      ctx: Self::Ctx<'vfs>,
93      link_rel: Self::PathRef<'_>,
94      target: Self::PathRef<'_>,
95    ) -> Self::Ctx<'vfs>;
96
97    /// Convertible from e.g. [`libc::stat64`].
98    ///
99    /// Contains a file type without needing to perform another syscall.
100    type Stat: Stat;
101
102    /// Convertible from e.g. [`fs::File`].
103    type File;
104    /// Typically just [`fs::OpenOptions`].
105    type FileOptions;
106
107    /// An open directory stream, like the result of [`fs::read_dir()`].
108    type Dir<'vfs>: DirectoryStream
109    where Self: 'vfs;
110    fn entry_rel<'vfs, 'dir, 's>(
111      name: <<<Self as VFS>::Dir<'vfs> as DirectoryStream>::DirEntry<'dir> as DirectoryEntry>::PathRef<'s>,
112    ) -> Self::PathRef<'s>;
113    fn entry_owned_rel<'vfs, 'dir>(
114      name: <<<Self as VFS>::Dir<'vfs> as DirectoryStream>::DirEntry<'dir> as DirectoryEntry>::OwnedPath,
115    ) -> Self::OwnedPath;
116
117    /// Read metadata from a file path and/or directory entry.
118    fn stat<'vfs>(
119      &'vfs self,
120      ctx: Self::Ctx<'vfs>,
121      rel: Self::PathRef<'_>,
122    ) -> Result<Self::Stat, Self::Err>;
123
124    /// Read the contents of a symbolic link.
125    fn read_link<'vfs>(
126      &'vfs self,
127      ctx: Self::Ctx<'vfs>,
128      rel: Self::PathRef<'_>,
129    ) -> Result<Self::OwnedPath, Self::Err>;
130
131    /// Open a file handle.
132    ///
133    /// While this crate is for directory crawling, opening files increases open file descriptor
134    /// count, so we must manage it along with our directory traversal.
135    fn open_file<'vfs>(
136      &'vfs self,
137      ctx: Self::Ctx<'vfs>,
138      rel: Self::PathRef<'_>,
139      opts: Self::FileOptions,
140    ) -> Result<Self::File, Self::Err>;
141
142    /// Open a directory stream.
143    fn open_dir<'vfs>(
144      &'vfs self,
145      ctx: Self::Ctx<'vfs>,
146      rel: Self::PathRef<'_>,
147    ) -> Result<Self::Dir<'vfs>, Self::Err>;
148  }
149}
150
151
152#[cfg(unix)]
153pub mod posix {}
154
155pub mod path_based {
156  use std::{
157    env, ffi, fs, io,
158    path::{Path, PathBuf},
159  };
160
161  use super::traits;
162
163  #[derive(Debug, Clone)]
164  #[repr(transparent)]
165  pub struct Stat(fs::Metadata);
166
167  impl traits::Stat for Stat {
168    type Mode = fs::FileType;
169    fn file_type(&self) -> Result<traits::FileType, Self::Mode> {
170      let ty = self.0.file_type();
171      if ty.is_symlink() {
172        return Ok(traits::FileType::Link);
173      }
174      if ty.is_file() {
175        return Ok(traits::FileType::File);
176      }
177      if ty.is_dir() {
178        return Ok(traits::FileType::Dir);
179      }
180      Err(ty)
181    }
182  }
183
184  cfg_if::cfg_if! {
185    if #[cfg(all(unix, feature = "nightly"))] {
186      #[derive(Debug)]
187      #[repr(transparent)]
188      pub struct DirectoryEntry(fs::DirEntry);
189
190      impl DirectoryEntry {
191        pub fn new(entry: fs::DirEntry) -> Self { Self(entry) }
192      }
193
194      impl traits::DirectoryEntry for DirectoryEntry {
195        type PathRef<'s> = &'s ffi::OsStr;
196        type OwnedPath = ffi::OsString;
197
198        fn name<'e>(&'e self) -> Self::PathRef<'e> {
199          use std::os::unix::fs::DirEntryExt2;
200          self.0.file_name_ref()
201        }
202        fn owned_name(path: Self::PathRef<'_>) -> Self::OwnedPath {
203          path.to_os_string()
204        }
205
206        type EagerMode = fs::FileType;
207
208        cfg_if::cfg_if! {
209          if #[cfg(any(
210            target_os = "solaris",
211            target_os = "illumos",
212            target_os = "haiku",
213            target_os = "vxworks",
214            target_os = "aix",
215            target_os = "nto",
216            target_os = "vita",
217          ))] {
218            fn eager_file_type(&self) -> Option<Result<traits::FileType, Self::EagerMode>> { None }
219          } else {
220            fn eager_file_type(&self) -> Option<Result<traits::FileType, Self::EagerMode>> {
221              let ty = self.0.file_type().unwrap();
222              if ty.is_symlink() {
223                return Some(Ok(traits::FileType::Link));
224              }
225              if ty.is_file() {
226                return Some(Ok(traits::FileType::File));
227              }
228              if ty.is_dir() {
229                return Some(Ok(traits::FileType::Dir));
230              }
231              Some(Err(ty))
232            }
233          }
234        }
235      }
236    } else {
237      #[derive(Debug)]
238      pub struct DirectoryEntry {
239        inner: fs::DirEntry,
240        name: ffi::OsString,
241      }
242
243      impl DirectoryEntry {
244        pub fn new(entry: fs::DirEntry) -> Self {
245          let name = entry.file_name();
246          Self {
247            inner: entry,
248            name,
249          }
250        }
251      }
252
253      impl traits::DirectoryEntry for DirectoryEntry {
254        type PathRef<'s> = &'s ffi::OsStr;
255        type OwnedPath = ffi::OsString;
256
257        fn name<'e>(&'e self) -> Self::PathRef<'e> {
258          &self.name
259        }
260        fn owned_name(path: Self::PathRef<'_>) -> Self::OwnedPath {
261          path.to_os_string()
262        }
263
264        type EagerMode = fs::FileType;
265
266        cfg_if::cfg_if! {
267          if #[cfg(any(
268            target_os = "solaris",
269            target_os = "illumos",
270            target_os = "haiku",
271            target_os = "vxworks",
272            target_os = "aix",
273            target_os = "nto",
274            target_os = "vita",
275          ))] {
276            fn eager_file_type(&self) -> Option<Result<traits::FileType, Self::EagerMode>> { None }
277          } else {
278            fn eager_file_type(&self) -> Option<Result<traits::FileType, Self::EagerMode>> {
279              let ty = self.inner.file_type().unwrap();
280              if ty.is_symlink() {
281                return Some(Ok(traits::FileType::Link));
282              }
283              if ty.is_file() {
284                return Some(Ok(traits::FileType::File));
285              }
286              if ty.is_dir() {
287                return Some(Ok(traits::FileType::Dir));
288              }
289              Some(Err(ty))
290            }
291          }
292        }
293      }
294    }
295  }
296
297  pub struct DirectoryStream {
298    inner: fs::ReadDir,
299    done: bool,
300  }
301
302  impl traits::DirectoryStream for DirectoryStream {
303    type DirEntry<'dir> = DirectoryEntry;
304    type Err = io::Error;
305
306    fn read_dir<'dir>(&'dir mut self) -> Result<Option<Self::DirEntry<'dir>>, Self::Err> {
307      if self.done {
308        return Ok(None);
309      }
310
311      loop {
312        use traits::DirectoryEntry as _;
313
314        let Some(result) = self.inner.next().transpose()? else {
315          self.done = true;
316          return Ok(None);
317        };
318        let entry = DirectoryEntry::new(result);
319        let name = entry.name();
320        if name == "." || name == ".." {
321          continue;
322        }
323        return Ok(Some(entry));
324      }
325    }
326  }
327
328  #[derive(Debug)]
329  pub struct VFS;
330
331  impl traits::VFS for VFS {
332    type Ctx<'vfs> = PathBuf;
333    type Err = io::Error;
334
335    type PathRef<'s> = &'s Path;
336    type OwnedPath = PathBuf;
337    fn path_ref<'s>(p: &'s Self::OwnedPath) -> Self::PathRef<'s> { p.as_ref() }
338
339    fn initial_context<'vfs>(&'vfs self) -> Result<Self::Ctx<'vfs>, Self::Err> {
340      env::current_dir()
341    }
342    fn join_context_dir<'vfs>(
343      &'vfs self,
344      ctx: Self::Ctx<'vfs>,
345      rel: Self::PathRef<'_>,
346    ) -> Self::Ctx<'vfs> {
347      ctx.join(rel)
348    }
349    fn join_context_link<'vfs>(
350      &'vfs self,
351      ctx: Self::Ctx<'vfs>,
352      _link_rel: Self::PathRef<'_>,
353      target: Self::PathRef<'_>,
354    ) -> Self::Ctx<'vfs> {
355      ctx.join(target)
356    }
357
358    type Stat = Stat;
359
360    type File = fs::File;
361    type FileOptions = fs::OpenOptions;
362
363    type Dir<'vfs> = DirectoryStream;
364    fn entry_rel<'vfs, 'dir, 's>(
365      name: <<<Self as traits::VFS>::Dir<'vfs> as traits::DirectoryStream>::DirEntry<'dir> as traits::DirectoryEntry>::PathRef<'s>,
366    ) -> Self::PathRef<'s> {
367      name.as_ref()
368    }
369    fn entry_owned_rel<'vfs, 'dir>(
370      name: <<<Self as traits::VFS>::Dir<'vfs> as traits::DirectoryStream>::DirEntry<'dir> as traits::DirectoryEntry>::OwnedPath,
371    ) -> Self::OwnedPath {
372      name.into()
373    }
374
375    fn stat<'vfs>(
376      &'vfs self,
377      ctx: Self::Ctx<'vfs>,
378      rel: Self::PathRef<'_>,
379    ) -> Result<Self::Stat, Self::Err> {
380      fs::symlink_metadata(ctx.join(rel)).map(Stat)
381    }
382
383    fn read_link<'vfs>(
384      &'vfs self,
385      ctx: Self::Ctx<'vfs>,
386      rel: Self::PathRef<'_>,
387    ) -> Result<Self::OwnedPath, Self::Err> {
388      fs::read_link(ctx.join(rel))
389    }
390
391    fn open_file<'vfs>(
392      &'vfs self,
393      ctx: Self::Ctx<'vfs>,
394      rel: Self::PathRef<'_>,
395      opts: Self::FileOptions,
396    ) -> Result<Self::File, Self::Err> {
397      opts.open(ctx.join(rel))
398    }
399
400    /* FIXME: handle max fd count here too!!!! */
401    fn open_dir<'vfs>(
402      &'vfs self,
403      ctx: Self::Ctx<'vfs>,
404      rel: Self::PathRef<'_>,
405    ) -> Result<Self::Dir<'vfs>, Self::Err> {
406      fs::read_dir(ctx.join(rel)).map(|inner| DirectoryStream { inner, done: false })
407    }
408  }
409
410  #[cfg(test)]
411  mod test {
412    use tempdir::TempDir;
413
414    use super::*;
415
416
417    #[test]
418    fn file() -> io::Result<()> {
419      let td = TempDir::new("asdf")?;
420      fs::write(td.path().join("f.txt"), "asdf\n")?;
421
422      use traits::VFS as _;
423      let vfs = VFS;
424      let ctx = vfs.initial_context()?;
425      let ctx = vfs.join_context_dir(ctx, td.path());
426
427      use traits::Stat as _;
428      let stat = vfs.stat(ctx.clone(), Path::new("f.txt"))?;
429      assert_eq!(stat.file_type(), Ok(traits::FileType::File));
430
431      let mut opts = fs::OpenOptions::new();
432      opts.read(true);
433      let mut f = vfs.open_file(ctx, Path::new("f.txt"), opts)?;
434
435      use io::Read;
436      let mut s = String::new();
437      f.read_to_string(&mut s)?;
438      assert_eq!(s, "asdf\n");
439
440      Ok(())
441    }
442
443    #[test]
444    fn link() -> io::Result<()> {
445      let td = TempDir::new("asdf")?;
446      cfg_if::cfg_if! {
447        if #[cfg(unix)] {
448          std::os::unix::fs::symlink("wow.txt", td.path().join("l.txt"))?;
449        } else {
450          std::os::windows::fs::symlink_file("wow.txt", td.path().join("l.txt"))?;
451        }
452      }
453
454      use traits::VFS as _;
455      let vfs = VFS;
456      let ctx = vfs.initial_context()?;
457      let ctx = vfs.join_context_dir(ctx, td.path());
458
459      use traits::Stat as _;
460      let stat = vfs.stat(ctx.clone(), Path::new("l.txt"))?;
461      assert_eq!(stat.file_type(), Ok(traits::FileType::Link));
462
463      let target = vfs.read_link(ctx, Path::new("l.txt"))?;
464      assert_eq!(&target, Path::new("wow.txt"));
465
466      Ok(())
467    }
468
469    #[test]
470    fn dir() -> io::Result<()> {
471      let td = TempDir::new("asdf")?;
472      fs::write(td.path().join("f.txt"), "asdf\n")?;
473      fs::create_dir(td.path().join("a"))?;
474      fs::write(td.path().join("a/g.txt"), "asdf2\n")?;
475      /* FIXME: test symlink context with VFS::join_context_link()! */
476
477      use traits::VFS as _;
478      let vfs = VFS;
479      let ctx = vfs.initial_context()?;
480
481      use traits::Stat as _;
482      let stat = vfs.stat(ctx.clone(), td.path())?;
483      assert_eq!(stat.file_type(), Ok(traits::FileType::Dir));
484
485      let mut opts = fs::OpenOptions::new();
486      opts.read(true);
487
488      use io::Read;
489      use traits::{DirectoryEntry as _, DirectoryStream as _, Stat as _};
490      let mut dir = vfs.open_dir(ctx.clone(), td.path())?;
491      let ctx = vfs.join_context_dir(ctx, td.path());
492      while let Some(entry) = dir.read_dir()? {
493        let ty = entry
494          .eager_file_type()
495          .map(|r| r.unwrap())
496          .unwrap_or_else(|| {
497            vfs
498              .stat(ctx.clone(), VFS::entry_rel(entry.name()))
499              .unwrap()
500              .file_type()
501              .unwrap()
502          });
503        match VFS::path_ref(&VFS::entry_owned_rel(DirectoryEntry::owned_name(
504          entry.name(),
505        )))
506        .to_str()
507        .unwrap()
508        {
509          "f.txt" => {
510            assert_eq!(ty, traits::FileType::File);
511            let mut f = vfs.open_file(ctx.clone(), VFS::entry_rel(entry.name()), opts.clone())?;
512            let mut s = String::new();
513            f.read_to_string(&mut s)?;
514            assert_eq!(s, "asdf\n");
515          },
516          "a" => {
517            assert_eq!(ty, traits::FileType::Dir);
518
519            let mut dir = vfs.open_dir(ctx.clone(), VFS::entry_rel(entry.name()))?;
520            let ctx = vfs.join_context_dir(ctx.clone(), VFS::entry_rel(entry.name()));
521            while let Some(entry) = dir.read_dir()? {
522              let ty = entry
523                .eager_file_type()
524                .map(|r| r.unwrap())
525                .unwrap_or_else(|| {
526                  vfs
527                    .stat(ctx.clone(), VFS::entry_rel(entry.name()))
528                    .unwrap()
529                    .file_type()
530                    .unwrap()
531                });
532              match DirectoryEntry::owned_name(entry.name()).to_str().unwrap() {
533                "g.txt" => {
534                  assert_eq!(ty, traits::FileType::File);
535                  let mut f =
536                    vfs.open_file(ctx.clone(), VFS::entry_rel(entry.name()), opts.clone())?;
537                  let mut s = String::new();
538                  f.read_to_string(&mut s)?;
539                  assert_eq!(s, "asdf2\n");
540                },
541                _ => unreachable!(),
542              }
543            }
544          },
545          _ => unreachable!(),
546        }
547      }
548
549      Ok(())
550    }
551  }
552}