mount/shell.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Platform-agnostic shell trait.
3//!
4//! [`PlatformShell`] is the seam where a thin per-platform adapter
5//! (FUSE on Linux, FSKit on macOS, ProjFS / CfAPI on Windows) plugs
6//! into the content-addressed core. The core implements this trait
7//! once, and each platform binding wraps it.
8//!
9//! Conceptually the trait is six pure operations: lookup, read,
10//! write, enumerate, attrs, invalidate. They mirror what every
11//! kernel-side filesystem hook ultimately needs to ask, so they can
12//! be implemented for an in-memory test mount, a Git-backed mount,
13//! a Heddle-state-backed mount, etc.
14
15use std::{
16 ffi::{OsStr, OsString},
17 time::SystemTime,
18};
19
20use objects::object::FileMode;
21
22use crate::error::Result;
23
24/// Identifier for a filesystem node within a single mount session.
25///
26/// Reserved value `1` is the root, mirroring FUSE convention. Beyond
27/// that, the core hands out opaque ids that are stable for the
28/// lifetime of the mount but may be invalidated by [`PlatformShell::invalidate`]
29/// when the underlying state moves.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct NodeId(pub u64);
32
33impl NodeId {
34 /// Root inode id. FUSE always starts here.
35 pub const ROOT: NodeId = NodeId(1);
36}
37
38/// What a filesystem entry is, structurally.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum NodeKind {
41 Directory,
42 File,
43 Symlink,
44}
45
46/// A single directory entry, returned from [`PlatformShell::lookup`]
47/// and [`PlatformShell::enumerate`].
48#[derive(Clone, Debug)]
49pub struct Entry {
50 pub node: NodeId,
51 pub name: OsString,
52 pub kind: NodeKind,
53 pub size: u64,
54 /// Unix mode bits, including type. Cached so the platform shell
55 /// can answer `attrs` without a second walk.
56 pub unix_mode: u32,
57}
58
59/// Stat-style attributes for a single node.
60#[derive(Clone, Copy, Debug)]
61pub struct Attrs {
62 pub node: NodeId,
63 pub kind: NodeKind,
64 pub size: u64,
65 pub unix_mode: u32,
66 pub nlink: u32,
67 /// Modification / change times. The mount has no per-blob clock,
68 /// so we report a single fixed timestamp captured when the mount
69 /// was created. This keeps `ls -l` from showing nonsense and
70 /// makes diffs against a stable reference deterministic.
71 pub mtime: SystemTime,
72}
73
74/// Platform-agnostic operations every adapter implements against
75/// a shared core. Names mirror the eventual FUSE callbacks (and the
76/// equivalent FSKit / ProjFS hooks) so the platform layer can be
77/// almost trivial.
78///
79/// ## Write lifecycle
80///
81/// Mount writes flow through three calls:
82///
83/// 1. [`write`](PlatformShell::write) — kernel issues a sequence of
84/// `write(offset, bytes)` calls against an open file. The core
85/// accumulates these in an in-memory hot-tier buffer keyed by
86/// `NodeId`.
87/// 2. [`flush`](PlatformShell::flush) — kernel signals the buffer
88/// can be made durable (mapped to FUSE's `flush` callback, which
89/// fires on `close(2)` and on explicit fsync). The core promotes
90/// the hot buffer to a CAS blob and records `path -> blob_oid` in
91/// the per-thread pending tree. Buffer is dropped.
92/// 3. [`release`](PlatformShell::release) — kernel signals the file
93/// is closed and the inode handle can be retired. The default
94/// contract: identical to flush. FUSE doesn't always issue
95/// `flush` cleanly on every close path, so adapters should call
96/// `release` here too as a belt-and-braces measure.
97///
98/// Implementations MAY also promote a hot buffer opportunistically
99/// (e.g. after an idle window) — this is a safety net for files that
100/// the kernel never explicitly closes.
101pub trait PlatformShell {
102 /// Look up `name` inside `parent`. Returns `None` for ENOENT.
103 fn lookup(&self, parent: NodeId, name: &OsStr) -> Result<Option<Entry>>;
104
105 /// Read up to `buf.len()` bytes from `node`, starting at `offset`.
106 /// Returns the number of bytes actually written into `buf`.
107 fn read(&self, node: NodeId, offset: u64, buf: &mut [u8]) -> Result<usize>;
108
109 /// Write `data` to `node` at `offset`. Returns bytes written.
110 fn write(&self, node: NodeId, offset: u64, data: &[u8]) -> Result<usize>;
111
112 /// List the children of `dir`.
113 fn enumerate(&self, dir: NodeId) -> Result<Vec<Entry>>;
114
115 /// Stat `node`.
116 fn attrs(&self, node: NodeId) -> Result<Attrs>;
117
118 /// Drop any cached identity for `node`. The platform layer calls
119 /// this when the underlying state moves and previously-handed-out
120 /// inode numbers may now point at the wrong content.
121 fn invalidate(&self, node: NodeId) -> Result<()>;
122
123 /// Promote any hot-tier buffer for `node` into a CAS blob. The
124 /// FUSE `flush` callback dispatches here (fires on `close(2)`
125 /// and explicit fsync). Default: no-op for read-only mounts.
126 fn flush(&self, _node: NodeId) -> Result<()> {
127 Ok(())
128 }
129
130 /// Final close of `node`. Adapters call this on FUSE `release`
131 /// so a buffer that survived a missed `flush` still gets
132 /// promoted before the inode handle is retired. Default:
133 /// identical to flush.
134 fn release(&self, node: NodeId) -> Result<()> {
135 self.flush(node)
136 }
137}
138
139/// Convert a Heddle [`FileMode`] into a node kind.
140pub(crate) fn kind_for_mode(mode: FileMode) -> NodeKind {
141 match mode {
142 FileMode::Normal | FileMode::Executable => NodeKind::File,
143 FileMode::Symlink => NodeKind::Symlink,
144 }
145}
146
147/// The unix mode bits for a directory. Trees don't carry a mode of
148/// their own — they're synthesised at materialization time — so we
149/// keep one canonical value here.
150pub(crate) const DIR_UNIX_MODE: u32 = 0o040755;