Skip to main content

kaish_vfs/
traits.rs

1//! Core VFS traits and types.
2
3use async_trait::async_trait;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8// DirEntry and DirEntryKind live in kaish-types.
9pub use kaish_types::{DirEntry, DirEntryKind, EffectiveAccess, PathAccess, ReadRange};
10
11/// Abstract filesystem interface.
12///
13/// All operations use paths relative to the filesystem root.
14/// For example, if a `LocalFs` is rooted at `/home/amy/project`,
15/// then `read("src/main.rs")` reads `/home/amy/project/src/main.rs`.
16#[async_trait]
17pub trait Filesystem: Send + Sync {
18    /// Read the entire contents of a file.
19    async fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
20
21    /// Read a (possibly partial) slice of a file.
22    ///
23    /// The default reads the whole file and slices in memory, which is correct
24    /// for any finite backend. Backends that cannot answer a whole-file read —
25    /// notably synthetic infinite devices like `/dev/zero`, where reading
26    /// "everything" is unbounded — override this to honour the requested byte
27    /// count directly and to reject a `None` range loudly rather than hang.
28    async fn read_range(&self, path: &Path, range: Option<ReadRange>) -> io::Result<Vec<u8>> {
29        let content = self.read(path).await?;
30        Ok(match range {
31            Some(r) => r.apply(&content),
32            None => content,
33        })
34    }
35
36    /// Write data to a file, creating it if it doesn't exist.
37    ///
38    /// Returns `Err` if the filesystem is read-only.
39    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()>;
40
41    /// Append data to a file, creating it if it doesn't exist.
42    ///
43    /// The default composes `read` (treating a missing file as empty) with
44    /// `write` of the concatenation, which is correct for any backend but
45    /// costs a read permission the caller may not have and is not atomic —
46    /// a writer landing between the read and the write is silently
47    /// overwritten. Backends that can answer a true `O_APPEND`-style append
48    /// — no read, one atomic write — override this to grant it. Backends
49    /// that must materialize state on first write (a copy-on-write overlay
50    /// snapshotting its base) should keep the default: it routes through
51    /// `write`, so materialization still happens correctly.
52    ///
53    /// Returns `Err` if the filesystem is read-only.
54    async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
55        let mut existing = match self.read(path).await {
56            Ok(content) => content,
57            Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
58            Err(e) => return Err(e),
59        };
60        existing.extend_from_slice(data);
61        self.write(path, &existing).await
62    }
63
64    /// List entries in a directory.
65    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
66
67    /// Get metadata for a file or directory, following symlinks.
68    ///
69    /// A dangling link is `NotFound`. Use `lstat` to see the link itself.
70    async fn stat(&self, path: &Path) -> io::Result<DirEntry>;
71
72    /// Create a directory (and parent directories if needed).
73    ///
74    /// Returns `Err` if the filesystem is read-only.
75    async fn mkdir(&self, path: &Path) -> io::Result<()>;
76
77    /// Remove a file, empty directory, or symlink.
78    ///
79    /// The final component is never followed: removing a symlink unlinks the
80    /// link and leaves its target untouched, even when the target is a
81    /// directory.
82    ///
83    /// Returns `Err` if the filesystem is read-only.
84    async fn remove(&self, path: &Path) -> io::Result<()>;
85
86    /// Set the modification time of an existing path.
87    ///
88    /// The default errors with `Unsupported`. Writable filesystems that track
89    /// timestamps override this; read-only mounts reject. There is deliberately
90    /// **no silent no-op** — a `touch` that cannot record the time must say so
91    /// rather than report success it didn't deliver.
92    async fn set_mtime(&self, path: &Path, mtime: SystemTime) -> io::Result<()> {
93        let _ = mtime;
94        Err(io::Error::new(
95            io::ErrorKind::Unsupported,
96            format!("set_mtime not supported for {}", path.display()),
97        ))
98    }
99
100    /// Returns true if this filesystem is read-only.
101    fn read_only(&self) -> bool;
102
103    /// What the kernel can do with one path on this filesystem.
104    ///
105    /// The query behind `test -r`, `test -w`, and `test -x`. Neither
106    /// [`Filesystem::read_only`] nor `DirEntry.permissions` answers on its
107    /// own — `MemoryFs` (writable) and `JobFs` (read-only) both report
108    /// `permissions: None`, and a `LocalFs::read_only` wrapper over an
109    /// OS-writable directory reports the write bit set. [`PathAccess::resolve`]
110    /// is where the two combine.
111    ///
112    /// The default is right for a filesystem that is uniformly read-only or
113    /// uniformly writable. `VfsRouter` overrides it to ask the mount that owns
114    /// the path.
115    ///
116    /// A backend that can be written must report a mode; an absent one is read
117    /// as read-only, and nothing checks that for you. See `docs/EMBEDDING.md`,
118    /// "Reporting file permissions".
119    ///
120    /// `OverlayFs` keeps the default and inherits its one inaccuracy: writes
121    /// always land in the upper, so a lower file whose mode clears `0o222`
122    /// reports unwritable while copy-up would write it.
123    ///
124    /// Errors exactly as `stat` does: a path that does not exist is an error,
125    /// not a `PathAccess` of all-false.
126    async fn path_access(&self, path: &Path) -> io::Result<PathAccess> {
127        let entry = self.stat(path).await?;
128        Ok(PathAccess::resolve(entry.permissions, self.read_only()))
129    }
130
131    /// Memory-resident content bytes this filesystem is holding, if it
132    /// tracks them.
133    ///
134    /// Memory-backed filesystems (`MemoryFs`, `OverlayFs` and its base
135    /// snapshots) keep an exact net counter — an overwrite charges the
136    /// delta, a remove credits — and return `Some`. Disk-backed filesystems
137    /// keep the default `None`: disk residency is the host's concern (page
138    /// cache, `df`); this counter is about RAM. Counts file content only,
139    /// not directory/symlink metadata. Feeds per-mount introspection and
140    /// eviction decisions.
141    fn resident_bytes(&self) -> Option<u64> {
142        None
143    }
144
145    /// Check if a path exists, following symlinks.
146    ///
147    /// A dangling link does not exist, and any error reads as `false`. Ask
148    /// `lstat` when the question is whether a link is present.
149    async fn exists(&self, path: &Path) -> bool {
150        self.stat(path).await.is_ok()
151    }
152
153    /// Rename (move) a file, directory, or symlink.
154    ///
155    /// Neither path follows its final component: a symlink source is moved as
156    /// a link, and a symlink at the destination is replaced, never written
157    /// through to its target.
158    ///
159    /// This is an atomic operation when source and destination are on the same
160    /// filesystem. The default implementation is remove-destination, copy,
161    /// delete, which is not atomic and does not move directories.
162    ///
163    /// Returns `Err` if the filesystem is read-only.
164    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
165        let entry = self.lstat(from).await?;
166        if entry.is_dir() {
167            return Err(io::Error::new(
168                io::ErrorKind::Unsupported,
169                "rename directories not supported by this filesystem",
170            ));
171        }
172        // Renaming a path to itself is a no-op; clearing the destination
173        // below would delete the source.
174        if same_name(from, to) {
175            return Ok(());
176        }
177        // Clear the destination first: `write` would follow a link left there.
178        match self.remove(to).await {
179            Ok(()) => {}
180            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
181            Err(error) => return Err(error),
182        }
183        if entry.is_symlink() {
184            let target = self.read_link(from).await?;
185            self.symlink(&target, to).await?;
186        } else {
187            let data = self.read(from).await?;
188            self.write(to, &data).await?;
189        }
190        self.remove(from).await?;
191        Ok(())
192    }
193
194    /// Get the real filesystem path for a VFS path.
195    ///
196    /// Returns `Some(path)` for backends backed by the real filesystem (like LocalFs),
197    /// or `None` for virtual backends (like MemoryFs).
198    ///
199    /// This is needed for tools like `git` that must use real paths with external libraries.
200    fn real_path(&self, path: &Path) -> Option<PathBuf> {
201        let _ = path;
202        None
203    }
204
205    /// Read the target of a symbolic link without following it.
206    ///
207    /// Returns the path the symlink points to. Use `stat` to follow symlinks.
208    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
209        let _ = path;
210        Err(io::Error::new(
211            io::ErrorKind::InvalidInput,
212            "symlinks not supported by this filesystem",
213        ))
214    }
215
216    /// Create a symbolic link.
217    ///
218    /// Creates a symlink at `link` pointing to `target`. The target is stored
219    /// verbatim; a relative target resolves from the link's directory, as in
220    /// `readlink`. An absolute target is refused with `InvalidInput` (see
221    /// [`refuse_absolute_target`]): a backend has no namespace to read it in,
222    /// and a tree of relative links moves intact. The router above the
223    /// backends rewrites an absolute target inside the same mount to the
224    /// relative form. `link` itself is never followed: an existing path there
225    /// is `AlreadyExists`.
226    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
227        let _ = (target, link);
228        Err(io::Error::new(
229            io::ErrorKind::InvalidInput,
230            "symlinks not supported by this filesystem",
231        ))
232    }
233
234    /// Get metadata for a path without following its final symlink.
235    ///
236    /// Unlike `stat`, this returns metadata about the symlink itself,
237    /// not the target it points to. A backend that supports symlinks must
238    /// override this: the default aliases `stat`, and the conformance suite
239    /// fails a backend whose `lstat` follows.
240    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
241        // Default: same as stat (for backends that don't support symlinks)
242        self.stat(path).await
243    }
244}
245
246/// Whether two paths spell the same name once `.` and `..` are resolved
247/// lexically and a leading `/` is ignored; `..` at the root stays there.
248fn same_name(a: &Path, b: &Path) -> bool {
249    let key = |p: &Path| -> Vec<std::ffi::OsString> {
250        let mut out: Vec<std::ffi::OsString> = Vec::new();
251        for component in p.components() {
252            match component {
253                std::path::Component::Normal(name) => out.push(name.to_os_string()),
254                std::path::Component::ParentDir => {
255                    out.pop();
256                }
257                _ => {}
258            }
259        }
260        out
261    };
262    key(a) == key(b)
263}
264
265/// The one refusal every backend gives an absolute symlink target.
266///
267/// The error names the fix: write the target relative to the link's
268/// directory.
269pub fn refuse_absolute_target(target: &Path) -> io::Result<()> {
270    if target.is_absolute() {
271        return Err(io::Error::new(
272            io::ErrorKind::InvalidInput,
273            format!(
274                "symlink target {} is absolute; write it relative to the link's directory",
275                target.display()
276            ),
277        ));
278    }
279    Ok(())
280}