heddle-mount 0.6.0

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
// SPDX-License-Identifier: Apache-2.0
//! Platform-agnostic shell trait.
//!
//! [`PlatformShell`] is the seam where a thin per-platform adapter
//! (FUSE on Linux, FSKit on macOS, ProjFS / CfAPI on Windows) plugs
//! into the content-addressed core. The core implements this trait
//! once, and each platform binding wraps it.
//!
//! Conceptually the trait is six pure operations: lookup, read,
//! write, enumerate, attrs, invalidate. They mirror what every
//! kernel-side filesystem hook ultimately needs to ask, so they can
//! be implemented for an in-memory test mount, a Git-backed mount,
//! a Heddle-state-backed mount, etc.

use std::{
    ffi::{OsStr, OsString},
    path::Path,
    time::SystemTime,
};

use objects::object::FileMode;

use crate::error::{MountError, Result};

/// Identifier for a filesystem node within a single mount session.
///
/// Reserved value `1` is the root, mirroring FUSE convention. Beyond
/// that, the core hands out opaque ids that are stable for the
/// lifetime of the mount but may be invalidated by [`PlatformShell::invalidate`]
/// when the underlying state moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct NodeId(pub u64);

impl NodeId {
    /// Root inode id. FUSE always starts here.
    pub const ROOT: NodeId = NodeId(1);
}

/// What a filesystem entry is, structurally.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeKind {
    Directory,
    File,
    Symlink,
}

/// A single directory entry, returned from [`PlatformShell::lookup`]
/// and [`PlatformShell::enumerate`].
#[derive(Clone, Debug)]
pub struct Entry {
    pub node: NodeId,
    pub name: OsString,
    pub kind: NodeKind,
    pub size: u64,
    /// Unix mode bits, including type. Cached so the platform shell
    /// can answer `attrs` without a second walk.
    pub unix_mode: u32,
}

/// Stat-style attributes for a single node.
#[derive(Clone, Copy, Debug)]
pub struct Attrs {
    pub node: NodeId,
    pub kind: NodeKind,
    pub size: u64,
    pub unix_mode: u32,
    pub nlink: u32,
    /// Modification / change times. The mount has no per-blob clock,
    /// so we report a single fixed timestamp captured when the mount
    /// was created. This keeps `ls -l` from showing nonsense and
    /// makes diffs against a stable reference deterministic.
    pub mtime: SystemTime,
}

/// Platform-agnostic operations every adapter implements against
/// a shared core. Names mirror the eventual FUSE callbacks (and the
/// equivalent FSKit / ProjFS hooks) so the platform layer can be
/// almost trivial.
///
/// ## Write lifecycle
///
/// Mount writes flow through three calls:
///
/// 1. [`write`](PlatformShell::write) — kernel issues a sequence of
///    `write(offset, bytes)` calls against an open file. The core
///    accumulates these in an in-memory hot-tier buffer keyed by
///    `NodeId`.
/// 2. [`flush`](PlatformShell::flush) — kernel signals the buffer
///    can be made durable (mapped to FUSE's `flush` callback, which
///    fires on `close(2)` and on explicit fsync). The core promotes
///    the hot buffer to a CAS blob and records `path -> blob_oid` in
///    the per-thread pending tree. Buffer is dropped.
/// 3. [`release`](PlatformShell::release) — kernel signals the file
///    is closed and the inode handle can be retired. The default
///    contract: identical to flush. FUSE doesn't always issue
///    `flush` cleanly on every close path, so adapters should call
///    `release` here too as a belt-and-braces measure.
///
/// Implementations MAY also promote a hot buffer opportunistically
/// (e.g. after an idle window) — this is a safety net for files that
/// the kernel never explicitly closes.
///
/// ## Platform notes
///
/// The three-call write lifecycle above describes the Linux/FUSE
/// path verbatim — `fuser` delivers each `write(2)` syscall as a
/// `write` callback, then `close(2)` triggers `flush` and `release`.
/// FSKit on macOS exposes the same per-write granularity.
///
/// On Windows, ProjFS does not intercept individual writes: after a
/// virtualized file is "hydrated" by the first read, subsequent
/// writes go straight to NTFS and ProjFS only notifies the provider
/// after the handle closes. The ProjFS adapter bridges this by
/// reading the now-fully-hydrated file at close time and synthesizing
/// a single `write(node, 0, full_contents)` + `flush(node)` against
/// this trait. The hot-tier per-write buffer is therefore a
/// Linux/FUSE (and FSKit) optimization — implementations of this
/// trait can rely on the buffer being non-empty only on platforms
/// that deliver per-write callbacks.
pub trait PlatformShell {
    /// Look up `name` inside `parent`. Returns `None` for ENOENT.
    fn lookup(&self, parent: NodeId, name: &OsStr) -> Result<Option<Entry>>;

    /// Read up to `buf.len()` bytes from `node`, starting at `offset`.
    /// Returns the number of bytes actually written into `buf`.
    fn read(&self, node: NodeId, offset: u64, buf: &mut [u8]) -> Result<usize>;

    /// Write `data` to `node` at `offset`. Returns bytes written.
    fn write(&self, node: NodeId, offset: u64, data: &[u8]) -> Result<usize>;

    /// List the children of `dir`.
    fn enumerate(&self, dir: NodeId) -> Result<Vec<Entry>>;

    /// Stat `node`.
    fn attrs(&self, node: NodeId) -> Result<Attrs>;

    /// Drop any cached identity for `node`. The platform layer calls
    /// this when the underlying state moves and previously-handed-out
    /// inode numbers may now point at the wrong content.
    fn invalidate(&self, node: NodeId) -> Result<()>;

    /// Promote any hot-tier buffer for `node` into a CAS blob. The
    /// FUSE `flush` callback dispatches here (fires on `close(2)`
    /// and explicit fsync). Default: no-op for read-only mounts.
    ///
    /// Lifecycle note: FUSE `flush` fires on *every* descriptor close
    /// — including the close of a `dup`-derived fd — so it can be
    /// invoked multiple times before the last open handle is gone.
    /// Implementations that maintain per-inode "is the directory
    /// entry still gone?" state (orphan tracking) MUST defer the
    /// final clear to [`Self::release`]; touching it here would let a
    /// surviving fd's next write republish the unlinked pathname.
    fn flush(&self, _node: NodeId) -> Result<()> {
        Ok(())
    }

    /// Final close of `node`. The FUSE `release` callback dispatches
    /// here; it fires once per `open(2)` after the last fd derived
    /// from that open is closed. This is the canonical "last close of
    /// the inode" signal — it is the right hook (NOT [`Self::flush`])
    /// for retiring per-inode lifecycle state like orphan-tracking
    /// markers or open-handle refcounts. Default: identical to flush
    /// so shells that do not maintain per-inode lifecycle state
    /// inherit a uniform contract.
    fn release(&self, node: NodeId) -> Result<()> {
        self.flush(node)
    }

    /// Notify the shell that a new open file handle for `node` has
    /// been minted. FUSE adapters call this on the `open` / `create`
    /// callbacks so the shell can maintain a per-inode open-handle
    /// refcount — used to time the [`Self::release`] cleanup against
    /// the *final* close instead of the first one. Default: no-op so
    /// shells without lifecycle state are unaffected.
    fn on_open(&self, _node: NodeId) -> Result<()> {
        Ok(())
    }

    /// Create a fresh regular file under `parent`. Mints a [`NodeId`]
    /// for the new file in the writable overlay and returns its
    /// [`Entry`]; subsequent [`write`](PlatformShell::write) calls
    /// land in the per-thread hot tier.
    ///
    /// When `exclusive` is true (`O_CREAT|O_EXCL`), the call must
    /// fail with [`MountError::AlreadyExists`] if `name` already
    /// resolves under `parent` (either in the captured tree or the
    /// pending tier). When `exclusive` is false, a hit on an
    /// existing entry is returned as-is (same shape as `lookup`).
    ///
    /// Default: [`MountError::ReadOnly`] — implementations that
    /// don't support mutation inherit a uniform errno.
    fn create_file(
        &self,
        _parent: NodeId,
        _name: &OsStr,
        _mode: FileMode,
        _exclusive: bool,
    ) -> Result<Entry> {
        Err(MountError::ReadOnly)
    }

    /// Create an empty directory under `parent` in the overlay.
    /// Returns the new directory's [`Entry`]. Fails with
    /// [`MountError::AlreadyExists`] when `name` already resolves.
    fn make_dir(&self, _parent: NodeId, _name: &OsStr) -> Result<Entry> {
        Err(MountError::ReadOnly)
    }

    /// Delete the file named `name` under `parent`. The captured-tree
    /// entry (if any) is tombstoned so [`lookup`](Self::lookup) /
    /// [`enumerate`](Self::enumerate) skip it; any pending-tier hot
    /// buffer or warm blob for the path is dropped.
    ///
    /// Fails with [`MountError::NotFound`] if `name` doesn't resolve,
    /// or [`MountError::IsADirectory`] if it resolves to a directory.
    fn unlink_entry(&self, _parent: NodeId, _name: &OsStr) -> Result<()> {
        Err(MountError::ReadOnly)
    }

    /// Remove the empty directory named `name` under `parent`. Fails
    /// with [`MountError::NotADirectory`] for a file, with
    /// [`MountError::NotEmpty`] when the directory still has visible
    /// children (across captured tree + pending tier), or
    /// [`MountError::NotFound`] when nothing resolves.
    fn rmdir_entry(&self, _parent: NodeId, _name: &OsStr) -> Result<()> {
        Err(MountError::ReadOnly)
    }

    /// Atomically rename `(old_parent, old_name)` to
    /// `(new_parent, new_name)`. Handles both same-directory and
    /// cross-directory cases. Replacing an existing entry of the
    /// same kind is allowed (POSIX semantics); replacing a directory
    /// with a file (or vice-versa) fails with
    /// [`MountError::IsADirectory`] / [`MountError::NotADirectory`].
    fn rename_entry(
        &self,
        _old_parent: NodeId,
        _old_name: &OsStr,
        _new_parent: NodeId,
        _new_name: &OsStr,
    ) -> Result<()> {
        Err(MountError::ReadOnly)
    }

    /// Same as [`Self::rename_entry`] but honours [`RenameOptions`] —
    /// in particular `no_replace`, which atomically refuses the rename
    /// when the destination already resolves. The check + the
    /// directory-entry mutation MUST happen under a single critical
    /// section to avoid a TOCTOU window between the existence check
    /// and the rename itself. Default: ignore options and dispatch to
    /// `rename_entry` (preserving the existing trait surface for
    /// shells that do not yet support flags).
    fn rename_entry_with_options(
        &self,
        old_parent: NodeId,
        old_name: &OsStr,
        new_parent: NodeId,
        new_name: &OsStr,
        _options: RenameOptions,
    ) -> Result<()> {
        self.rename_entry(old_parent, old_name, new_parent, new_name)
    }

    /// Apply attribute updates to `node`. Returns the post-update
    /// [`Attrs`] so callers can reply without a second `getattr`
    /// round trip. See [`AttrUpdate`] for which fields the overlay
    /// actually persists; unsupported fields are no-ops.
    fn set_attrs(&self, _node: NodeId, _update: AttrUpdate) -> Result<Attrs> {
        Err(MountError::ReadOnly)
    }

    /// Create a symbolic link named `name` under `parent` whose
    /// target is the byte-equivalent of `target`. Returns the new
    /// link's [`Entry`].
    fn create_symlink(&self, _parent: NodeId, _name: &OsStr, _target: &Path) -> Result<Entry> {
        Err(MountError::ReadOnly)
    }

    /// Read the target of a symbolic link `node`. Returns the raw
    /// bytes of the link target (which may not be valid UTF-8 on
    /// some systems, hence [`OsString`]).
    fn read_link(&self, _node: NodeId) -> Result<OsString> {
        Err(MountError::ReadOnly)
    }
}

/// Optional fields a caller may update via
/// [`PlatformShell::set_attrs`]. Every field is `Option<_>`; `None`
/// means "leave alone" (the kernel passes `None` for slots the
/// `chmod`/`chown`/`truncate`/`utimensat` call didn't touch).
///
/// Heddle's tree model only carries three modes ([`FileMode::Normal`],
/// [`FileMode::Executable`], [`FileMode::Symlink`]) — see
/// `crates/objects/src/object/tree_types.rs`. A `chmod` that flips
/// the user-executable bit (`0o100`) maps to the closest mode; bits
/// outside that don't persist across `capture`.
#[derive(Clone, Copy, Debug, Default)]
pub struct AttrUpdate {
    /// New unix mode bits (including the type bits). When set, the
    /// shell folds the user-executable bit into the captured
    /// [`FileMode`]; other bits don't persist.
    pub mode: Option<u32>,
    /// New uid. The mount has no per-node uid storage (every node
    /// reports the mount-owner's uid); shells may accept this as a
    /// no-op so `chown` doesn't return an error to callers that
    /// don't actually need ownership tracking.
    pub uid: Option<u32>,
    /// New gid. Same no-op contract as `uid`.
    pub gid: Option<u32>,
    /// New size. Truncates the hot-tier buffer (or seeds one from
    /// the durable predecessor and truncates) when set. `O_TRUNC`
    /// on the kernel side delivers `setattr(size=0)` before the
    /// first `write`.
    pub size: Option<u64>,
    /// New mtime in seconds since the UNIX epoch. The overlay has
    /// no per-node mtime storage today; shells accept this as a
    /// no-op so the kernel's `utimensat` doesn't return an error.
    pub mtime_sec: Option<i64>,
}

/// Convert a Heddle [`FileMode`] into a node kind.
pub(crate) fn kind_for_mode(mode: FileMode) -> NodeKind {
    match mode {
        FileMode::Normal | FileMode::Executable => NodeKind::File,
        FileMode::Symlink => NodeKind::Symlink,
    }
}

/// The unix mode bits for a directory. Trees don't carry a mode of
/// their own — they're synthesised at materialization time — so we
/// keep one canonical value here.
pub(crate) const DIR_UNIX_MODE: u32 = 0o040755;

/// Optional flags for [`PlatformShell::rename_entry_with_options`].
/// Mirrors the subset of Linux `renameat2(2)` flags the mount
/// supports; non-applicable flags on non-Linux adapters can be left
/// as their defaults.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenameOptions {
    /// `RENAME_NOREPLACE`: refuse the rename with [`MountError::AlreadyExists`]
    /// when the destination already resolves. Must be enforced inside
    /// the same critical section as the rename so a concurrent writer
    /// cannot install the destination between the check and the
    /// mutation.
    pub no_replace: bool,
}

#[cfg(test)]
mod tests {
    use std::{cell::Cell, ffi::OsStr, time::UNIX_EPOCH};

    use super::*;

    /// Minimal `PlatformShell` impl that supplies only the required
    /// methods, so the test pins the *default* trait bodies for
    /// every optional write-side hook. Tracks how often `flush` and
    /// `rename_entry` are invoked so the delegation defaults
    /// (`release` → `flush`, `rename_entry_with_options` →
    /// `rename_entry`) can be observed.
    #[derive(Default)]
    struct StubShell {
        flush_calls: Cell<u32>,
        rename_calls: Cell<u32>,
    }

    impl PlatformShell for StubShell {
        fn lookup(&self, _parent: NodeId, _name: &OsStr) -> Result<Option<Entry>> {
            Ok(None)
        }
        fn read(&self, _node: NodeId, _offset: u64, _buf: &mut [u8]) -> Result<usize> {
            Ok(0)
        }
        fn write(&self, _node: NodeId, _offset: u64, data: &[u8]) -> Result<usize> {
            Ok(data.len())
        }
        fn enumerate(&self, _dir: NodeId) -> Result<Vec<Entry>> {
            Ok(Vec::new())
        }
        fn attrs(&self, node: NodeId) -> Result<Attrs> {
            Ok(Attrs {
                node,
                kind: NodeKind::File,
                size: 0,
                unix_mode: 0o100644,
                nlink: 1,
                mtime: UNIX_EPOCH,
            })
        }
        fn invalidate(&self, _node: NodeId) -> Result<()> {
            Ok(())
        }
        // Override flush so we can observe that `release`'s default
        // delegates here. Everything else stays on the trait default.
        fn flush(&self, _node: NodeId) -> Result<()> {
            self.flush_calls.set(self.flush_calls.get() + 1);
            Ok(())
        }
        // Override rename_entry so we can observe that
        // `rename_entry_with_options`'s default delegates here.
        fn rename_entry(&self, _op: NodeId, _on: &OsStr, _np: NodeId, _nn: &OsStr) -> Result<()> {
            self.rename_calls.set(self.rename_calls.get() + 1);
            Ok(())
        }
    }

    fn is_read_only<T>(r: Result<T>) -> bool {
        matches!(r, Err(MountError::ReadOnly))
    }

    #[test]
    fn write_side_defaults_return_read_only() {
        let s = StubShell::default();
        let p = NodeId::ROOT;
        let name = OsStr::new("x");

        assert!(is_read_only(s.create_file(
            p,
            name,
            FileMode::Normal,
            false
        ),));
        assert!(is_read_only(s.make_dir(p, name)));
        assert!(is_read_only(s.unlink_entry(p, name)));
        assert!(is_read_only(s.rmdir_entry(p, name)));
        assert!(is_read_only(s.set_attrs(NodeId(2), AttrUpdate::default())));
        assert!(is_read_only(s.create_symlink(p, name, Path::new("target")),));
        assert!(is_read_only(s.read_link(NodeId(2))));
    }

    #[test]
    fn on_open_default_is_noop() {
        let s = StubShell::default();
        assert!(s.on_open(NodeId(7)).is_ok());
    }

    #[test]
    fn release_default_delegates_to_flush() {
        let s = StubShell::default();
        assert_eq!(s.flush_calls.get(), 0);
        s.release(NodeId(3)).expect("release");
        assert_eq!(
            s.flush_calls.get(),
            1,
            "release default must invoke flush exactly once",
        );
    }

    #[test]
    fn rename_with_options_default_delegates_to_rename_entry() {
        let s = StubShell::default();
        let opts = RenameOptions { no_replace: true };
        // Default impl ignores the options and forwards to
        // `rename_entry` — observe the delegation via the call count.
        s.rename_entry_with_options(NodeId(1), OsStr::new("a"), NodeId(1), OsStr::new("b"), opts)
            .expect("rename");
        assert_eq!(s.rename_calls.get(), 1);
        assert!(opts.no_replace, "RenameOptions field survives copy");
        assert_eq!(
            RenameOptions::default(),
            RenameOptions { no_replace: false }
        );
    }

    #[test]
    fn kind_for_mode_maps_each_file_mode() {
        assert_eq!(kind_for_mode(FileMode::Normal), NodeKind::File);
        assert_eq!(kind_for_mode(FileMode::Executable), NodeKind::File);
        assert_eq!(kind_for_mode(FileMode::Symlink), NodeKind::Symlink);
    }
}