# mount
Heddle's content-addressed mount. The crate exposes virtualized
threads as a directory tree backed by the object store.
## Architecture
```
PlatformShell trait ← thin platform adapters
(FuseShell, FSKitShell) (one per OS)
↓
ContentAddressedMount ← pure Rust core
↓
crates/repo + crates/objects
```
The trait is in `src/shell.rs`. The core is in `src/core.rs`. Each
adapter is its own `cfg`-gated module.
## Cargo features
| `fuse` | Linux | Compiles `fuser` and the `FuseShell` adapter. |
| `fskit` | macOS | Compiles the Swift adapter and the `FSKitShell`. |
| `projfs` | Windows | Compiles the `windows` ProjFS bindings and the `ProjFsShell` adapter. |
| `nfs` | any | Compiles the in-process NFSv3 fallback (`NfsShell`). |
All are off by default. The default build of the workspace runs
on every OS and produces a crate with the trait + core only — no
mountable shell.
```bash
# Linux dev box
cargo build -p mount --features fuse
# macOS dev box
cargo build -p mount --features fskit
# Windows dev box
cargo build -p mount --features projfs
```
## Linux runtime requirements (FUSE)
The FUSE shell is the daily-use path on Linux. To mount, the host
needs:
1. **`/dev/fuse` available and readable by the mount-owner.** Most
distros ship this enabled in the kernel; containers may need
`--device=/dev/fuse --cap-add SYS_ADMIN`.
2. **`fusermount3` on `PATH`.** This is the SUID helper that
actually publishes the mount to the kernel. Debian/Ubuntu ships
it in `fuse3`; RHEL/Fedora in `fuse-libs` + `fuse3`. Install with:
```bash
sudo apt-get install fuse3 libfuse3-dev sudo dnf install fuse3 fuse3-devel ```
3. **No special `/etc/fuse.conf` configuration is required for the
default mount.** The shell uses `Owner`-scoped ACL (the kernel
gates access to the mountpoint at the user level), so
`user_allow_other` is not needed. If you want to allow other
local users (or root) to read into the mount — uncommon for
heddle's single-user workflow — that's where `user_allow_other`
in `/etc/fuse.conf` comes in, paired with explicit
`MountOption::AllowOther` / `AllowRoot` in
[`crate::fuse::default_config`]. The default shell does *not*
enable `AutoUnmount` for this reason: fuser 0.17 rejects
`AutoUnmount` without `AllowOther`/`AllowRoot`, and we'd rather
require an explicit unmount path than depend on host-level
policy.
4. **The `fuse` cargo feature**, propagated through
`heddle-cli`'s `mount` feature.
5. **No kernel-version floor for caching or shared `mmap`.** The
shell runs in the kernel's default **cached** mode (heddle#87): it
does *not* return `FOPEN_DIRECT_IO`, and instead hands back
`FOPEN_KEEP_CACHE` from `open`/`create` so the page cache stays
live across opens (see [`crate::fuse::FuseShell::open`]). Repeated
reads of unchanged content are served straight from the page cache
without a FUSE round-trip, and shared `mmap(MAP_SHARED, ...)` works
out of the box — no `FUSE_DIRECT_IO_ALLOW_MMAP` capability, hence
no Linux 5.16+ requirement. rust-analyzer, cargo, IDEs, and
`grep --mmap` all work on heddle-mounted trees on any FUSE-capable
kernel.
Coherence comes from **active invalidation**, the same model FSKit
uses on macOS: every content-changing callback (`write`, `setattr`)
pushes a `notify_inval_inode` to the kernel, and every dentry-
changing callback (`create`/`mkdir`/`mknod`/`symlink`/`unlink`/
`rmdir`/`rename`) pushes a `notify_inval_entry`, so the kernel can
never serve stale cached bytes, attrs, or dentries. Those
notifications are dispatched from a dedicated invalidator thread
(never inline from a callback — that deadlocks on the kernel inode
lock); see the `fuse.rs` module docs ("Invalidation contract") for
the per-callback mapping.
### Cleaning up a stale FUSE mount
The clean-shutdown path is a `BackgroundSession::drop` (the CLI's
`MountHandle::unmount`). If the daemon is `kill -9`'d or the
session is leaked, the mount lingers — userspace sees the
content-addressed view, but no daemon is answering. To clear:
```bash
fusermount3 -u <mountpoint>
```
If `fusermount3 -u` fails with `Device or resource busy`, kill any
process with a working directory or open file descriptor under the
mountpoint (`lsof <mountpoint>`), then retry. As a last resort,
`umount -l <mountpoint>` does a lazy detach — the kernel hides the
mount from new accesses while waiting for outstanding handles to
close.
### Errno mapping
The shell translates a `MountError` into a kernel-replied errno via
[`crate::error::MountError::to_errno`]. Today's mapping:
| `NotFound` | `ENOENT` | "No such file or directory" |
| `UnknownThread` | `ENOENT` | (a thread name we can't resolve looks like a path miss to userspace) |
| `Stale` | `ESTALE` | "Stale file handle" — usually the inode was invalidated mid-flight |
| `NotADirectory` | `ENOTDIR` | "Not a directory" |
| `ReadOnly` | `EROFS` | "Read-only file system" |
| `Store(NotFound)` | `ENOENT` | underlying object missing |
| `Store(Io)` | passthrough | the wrapped `std::io::Error`'s `raw_os_error()` if present, else `EIO` |
| `Store(_)` | `EIO` | "Input/output error" — generic catch-all for unmapped object-store errors |
Any panic deep in a `PlatformShell` call surfaces to userspace as
`EIO` (the FUSE shell wraps each callback in `guard_call`; see
[`crate::fuse`] module docs). Without that guard, a single panic in
a worker thread would either wedge the mount (kernel waits for
replies that never come) or — post Rust 1.81 — abort the daemon
process across an `extern "C"` boundary inside `fuser`. The
`fuse::tests::guard_call_translates_panic_to_eio` test pins the
contract.
### Platform feature parity
The Linux FUSE shell, macOS FSKit shell, and Windows ProjFS shell
all share the same [`PlatformShell`] core, so the set of
**heddle-visible** operations is identical across platforms:
`lookup`, `read`, `write`, `enumerate`, `attrs`, `flush`, `release`,
`invalidate`. What differs is the **kernel-side capabilities** each
backend exposes to userspace:
| Per-write callbacks | yes | yes | no (close-time only) |
| Extended attributes | not wired | not wired | n/a |
| `mkdir` / `create` | not wired (ENOSYS) | not wired | not wired |
| `setattr(size)` (O_TRUNC) | not wired (ENOSYS) | not wired | n/a |
| Symlink readback | works (read-only) | works (read-only) | works (read-only) |
| Shared `mmap` on mounted files | yes (kernel 5.16+; falls back to ENODEV on older kernels) | yes (UBC-backed) | yes (projected files are physical on the volume) |
| ACLs / chmod through mount | not honored | not honored | n/a |
| Auto-unmount on daemon death | off by default | n/a | n/a |
| Panic-recovery in callbacks | yes (`guard_call`) | yes (`guarded_c_int`) | yes (`guarded_hresult`) |
"Not wired" means heddle's adapter doesn't override the framework's
default — typically `ENOSYS`. Wiring an op is a matter of
implementing the relevant trait method *and* adding a
[`PlatformShell`] hook for it; the existing seven ops are sufficient
for the daily "mount → read → edit-existing-file → capture" loop
that drives the 2-day milestone.
## macOS install requirements (FSKit)
The FSKit shell needs:
1. **macOS 26.0+** for native FSKit path-backed mounts. The Swift
adapter now uses FSKit V2 URL resources, so the CLI falls back
to NFS on older macOS releases with a clear notice instead of
attempting the native FSKit path.
2. **Xcode command line tools** (`xcode-select --install`). The
`build.rs` invokes `swiftc` to compile
`swift/HeddleFSKit/HeddleFSKit.swift` into a static library.
Override the path via `HEDDLE_SWIFTC=/path/to/swiftc` and the
Swift runtime location via `HEDDLE_SWIFT_RUNTIME=/path/to/usr/lib/swift/macosx`.
3. **The `com.apple.developer.fskit.fsmodule` entitlement** on the
binary that loads the FSKit module. Apple gates this entitlement
to apps with a paid Developer account; the binary must be
code-signed with a provisioning profile that grants it. For
distribution, that means a Developer ID certificate + the
FSKit entitlement attached to the heddle CLI binary.
4. **A code-signed build.** `codesign --entitlements heddle.entitlements
--sign "Developer ID Application: ..." target/release/heddle`.
For local development you can self-sign with an ad-hoc identity,
but the kernel will refuse to load an FSKit module from an
unsigned binary on production macOS configurations.
5. **(Optional) Disable SIP** for local development if you want to
skip the entitlement requirement. Not recommended; only useful
for proof-of-concept work on a dev VM.
### Running the FSKit tests locally
The unit tests in `src/fskit/mod.rs` and the smoke test in
`tests/fskit_smoke.rs` are all `#[ignore]` by default. To run:
```bash
# Unit lifecycle test (no real mount; just constructor + drop)
cargo test -p mount --features fskit fskit::tests -- --ignored
# Full mount smoke test (requires macOS 26.0 + entitlements)
HEDDLE_FSKIT_AVAILABLE=1 \
cargo test -p mount --features fskit --test fskit_smoke -- --ignored
```
The integration test will skip itself unless `HEDDLE_FSKIT_AVAILABLE=1`
is set, so a generic CI runner won't fail on it accidentally.
## Windows runtime requirements (ProjFS)
The ProjFS shell is the daily-use path on Windows. To mount, the
host needs:
1. **Windows 10 1809+, Windows 11, or Windows Server 2019+.**
`ProjectedFSLib.dll` was added in 1809; earlier builds will
fail at `LoadLibraryW`. [`ProjFsShell::is_runtime_available`]
probes for the DLL and reports failure when missing.
2. **The "Projected File System" Windows optional feature
installed.** This is NOT enabled by default on most SKUs. One-time
enable from an admin PowerShell:
```powershell
# Windows 10 / 11 client SKUs
Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS
# Windows Server SKUs (the feature is named differently)
Enable-WindowsOptionalFeature -Online -FeatureName Projected-FS
```
The enable is in-place and doesn't require a reboot for new
processes. Heddle's CLI will fall back to the NFS shell if the
feature is missing, but the user-visible performance is much
better with ProjFS — installing it once is the recommended
workflow.
3. **The virtualization root on NTFS.** Paths under
`%USERPROFILE%`, `%LOCALAPPDATA%`, or any normal NTFS drive
work. ReFS, FAT32, and exFAT volumes don't support reparse
points and will fail at `PrjMarkDirectoryAsPlaceholder` with
`ERROR_NOT_SUPPORTED`.
4. **The `projfs` cargo feature**, propagated through
`heddle-cli`'s `mount` feature.
5. **No admin rights at mount time.** Once the optional feature is
installed (one-time admin step), routine mount/unmount is
unprivileged — the kernel-side adapter handles the privilege
transition.
### Cleaning up a stale ProjFS mount
The clean-shutdown path is `ProjFsSession::drop`, which calls
`PrjStopVirtualizing`. If the daemon is force-killed, the
virtualization root stays *marked* (the directory keeps its
reparse metadata) but no provider answers callbacks. Reading
already-hydrated files works (they're regular NTFS files at that
point); reading a placeholder hangs the kernel until it times
out.
To clear: re-mount the same path with a fresh `ProjFsShell`
session — the sidecar GUID file in the parent directory pins the
instance identity so the kernel re-attaches without an error.
Alternatively, delete the entire virtualization root tree from
PowerShell:
```powershell
Remove-Item -Recurse -Force <root>
```
Deleting the root strips both the reparse metadata and any
hydrated placeholders. The sidecar GUID file has two possible
locations depending on parent-directory writability at mount
time — `<parent>\.<basename>.heddle-projfs-id` is the default,
`<root>\.heddle-projfs-id` is the fallback when the parent ACL
refuses writes — both should be removed to fully reset the
instance identity.
### Running the ProjFS tests locally
The unit tests in `src/projfs.rs::tests` and the smoke tests in
`tests/projfs_smoke.rs` are all `#[ignore]` by default. To run:
```powershell
# Smoke tests (require the optional feature)
$env:HEDDLE_PROJFS_AVAILABLE = "1"
cargo test -p mount --features projfs --test projfs_smoke -- --ignored
# Unit lifecycle tests (no real mount; just constructor + drop +
# is_runtime_available probe)
cargo test -p mount --features projfs --lib projfs:: -- --ignored
```
The smoke tests will skip themselves unless
`HEDDLE_PROJFS_AVAILABLE=1` is set, so a Windows host without the
optional feature won't fail them accidentally.
## Per-thread overlay semantics (write path)
Every mount is bound to exactly one thread (`mount.thread`). All
writes the kernel issues against the mountpoint land in a
**per-thread overlay** layered on top of the underlying CAS; the
captured tree is never mutated in place. The overlay is the diff
between "what the thread looked like at mount time" and "what the
agent has written since".
The overlay has six pieces, all in process memory until
[`ContentAddressedMount::capture`] folds them into a new heddle
state:
| `hot` buffer | In-flight `pwrite` bytes per open NodeId | Drained to `warm` first, then folded as a file blob |
| `warm` tier | Path → CAS-promoted blob (post-`flush`/`close`) | File blob in the destination tree |
| `tombstones` | Paths the mount has `unlink`'d | Removes the captured entry; prunes empty parent dirs |
| `dir_tombstones` | Captured-tree directories the mount has `rmdir`'d | Drops the whole subtree from the destination tree |
| `explicit_dirs` | Empty `mkdir`s with no children yet | Materialized as 0-entry subtree |
| `symlinks` | Path → link target bytes | Hashed once, planted as a `Symlink` tree entry |
Lock ordering inside the mount is **state → pending → inodes**;
every write-side op holds them in that order to avoid the deadlock
shapes Codex caught early in development. See the `MountInner`
docstring in `src/core.rs`.
### What `capture` does
When the agent runs `heddle capture`, the orchestrator calls
[`ContentAddressedMount::capture`] which:
1. Drains every open hot buffer to the warm tier (no agent ever
"loses" an in-flight write — the close handshake is the only
point of fragility, and the safety-sweep thread covers the
process-killed-without-close case via the `idle_after` window).
2. Folds the warm tier + tombstones + dir_tombstones +
explicit_dirs + symlinks into a fresh root tree, descending into
each pending sub-path and merging against the captured
counterpart. Empty captured-dir entries prune naturally.
3. Records a new `State` referencing that root tree and advances
the thread's HEAD. Attribution comes from the repo's default
path (`HEDDLE_AGENT_*` env + repo config + principal).
4. Clears the entire pending tier. The next write starts with a
fresh overlay.
The whole pass is one walk of the pending map — no worktree-scan,
no re-hash of any blob the agent already wrote through `flush`.
The result is content-addressed: two agents that wrote identical
bytes to different paths produce **one** CAS blob, not two.
### What does NOT persist across capture
The overlay is intentionally lightweight; some kernel-side concepts
don't have a tree-level home and surface as no-ops or
approximations:
- **`chmod` other than `+x`.** Heddle's [`FileMode`] is a three-way
enum (`Normal` / `Executable` / `Symlink`); arbitrary 9-bit perm
masks fold into the closest mode at capture time. The
user-executable bit (`0o100`) flips Normal ↔ Executable; the
rest of the bits (group/other read/write, setuid, sticky) are
not modelled and don't survive `capture`.
- **`chown` / uid / gid.** The mount reports every node as owned by
the mount-owner's uid + primary gid. `setattr(uid)` /
`setattr(gid)` are accepted as no-ops so callers don't get
`EPERM` from a chown that wouldn't have had a visible effect
anyway.
- **Per-node mtime / atime / ctime.** Every node reports the
mount's `mounted_at` timestamp. `setattr(mtime)` / `utimensat`
are accepted as no-ops.
- **Hard links.** `link(2)` returns `EPERM`. The CAS already
de-duplicates by content hash, so two paths with the same bytes
share one blob — but they're independent tree entries, not
aliased inodes.
- **`mknod` for device / FIFO / socket nodes.** Only regular files
(`S_IFREG`) are accepted; everything else returns `EPERM`.
Heddle's tree doesn't model device files.
- **Captured-directory rename.** Renaming an entirely overlay-only
directory (one that the agent `mkdir`'d in this session) works;
renaming a captured directory returns `EINVAL`. Cross-tree
directory renames would need a recursive tombstone + warm-tier
rewrite pass that's out of scope for the cargo / git / npm
daily-use path.
### What about `inotify` / `fanotify`?
Not delivered. The mount doesn't emit filesystem-change events
for editors / file-watchers / `cargo watch`-style tooling. This
is a separate substrate decision tracked in a follow-up issue;
the cargo / git path doesn't need it.
## Status
- Linux FUSE shell: production. Used by the heddle CLI when
`--features mount` is enabled. Production hardening (panic
recovery in every callback, write-through-mount round-trip,
concurrent-read smoke, clean unmount on drop) is locked in by
the `fuse-smoke` CI matrix entry; see
`.github/workflows/rust-tests.yml` and `tests/fuse_mount.rs`.
- macOS FSKit shell: scaffolded. The C ABI between the Swift
adapter and the Rust trampolines is wired end-to-end; the
`PlatformShell` dispatch is exercised by the unit test. The
volume is published through the `.fsmodule` System Extension
path; the CLI no longer exposes an in-process FSKit mount stub.
Finishing this needs release-engineering ownership of the
`.fsmodule` bundle and FSKit entitlement.
- Windows ProjFS shell: production-ready (heddle#75). The CLI
routes Windows daily-use traffic through `ProjFsShell` with an
NFS fallback when the optional feature is missing. Production
hardening (panic recovery in every trampoline, dir-enumeration
cursor across multi-call `get_dir_enum`, instance-ID sidecar
outside the projection envelope, close-modified bridge for the
write path) is locked in by the `projfs-smoke` CI matrix entry;
see `.github/workflows/rust-tests.yml` and
`tests/projfs_smoke.rs`. CfAPI (the cloud-files variant) is
still unimplemented and a follow-up only if a remote-fetch
product surface lands.
## CLI integration
The CLI's mount lifecycle (`crates/cli/src/cli/commands/mount_lifecycle.rs`)
currently dispatches through `FuseShell` only — it's gated on
`#[cfg(all(target_os = "linux", feature = "mount"))]`. Adding
FSKit dispatch is a follow-up:
1. Add a `#[cfg(all(target_os = "macos", feature = "mount"))]`
sibling module that mirrors the existing `linux` module but
launches the `.fsmodule` System Extension bootstrap path.
2. Re-export `MountHandle` / `spawn_mount_for_thread` /
`unmount_thread_if_mounted` from whichever module is
compiled in.
3. Add the `fskit` feature to the CLI's `mount` feature
propagation in `crates/cli/Cargo.toml`.
The trait layer means the dispatch site stays one line per
backend; nothing in `mount_lifecycle.rs`'s consumer code needs to
change.