26 KiB
Layered / Nested VFS
Status: Operational — core bind resolution, context refactor, config extraction, and path rebasing complete
Date: 2026-03-11
Last updated: 2026-03-11T23:20
1. Problem
Today every VFS is a flat, top-level mount (home, root, www, user:<uuid>).
Addresses use mount:/path everywhere — API routes, AclEditor, MountManager.resolve(), the FileBrowser.
This prevents a key capability: mounting one VFS inside another (like Linux's mount --bind).
Use-cases:
| Scenario | What you want |
|---|---|
| Shared media library | Mount media at home:/assets/shared so a user sees it in their tree |
| Multi-site builder | Mount site-b at www:/sites/b — each sub-site is its own VFS with its own ACL |
| User collab folders | Mount user:Alice:/public inside user:Bob:/collabs/alice |
| Read-only overlays | Mount a system templates VFS read-only at home:/templates |
2. Current Architecture (Status Quo)
┌─────────────────────────────────────────────────────────┐
│ API route: /api/vfs/{op}/{mount}/{path*} │
└──────────────┬──────────────────────────────────────────┘
│
resolveMount(c, userId) ← flat lookup
│
┌───────────▼───────────┐
│ MountManager │ findByName("www") → IMount
│ (vfs.json array) │ findByName("home") → root + userId
└───────────┬───────────┘
│
createVFS(mount, ownerId, callerId)
│
┌───────────▼───────────┐
│ AclVfsClient │ ← wraps LocalVFS with path-scoped ACL
│ (vfs-settings.json) │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ LocalVFS │ ← root-jailed fs operations
└───────────────────────┘
Key constraints
MountManageris a flat array — no nesting concept.resolveMount()returns a single{ mount, ownerId }— one VFS driver per request.AclVfsClient.#guard()checksresourceChain(ownerId, subpath)against a singlevfs-settings.json.INode.pathis always relative to the mount root — no cross-mount references.- Frontend (
FileBrowser,AclEditor) usesmount:pathstrings as the canonical address.
3. Proposed Design: Bind Mounts
3.1 Mental model: mount --bind
This is not an overlay filesystem. It's a bind mount — the same semantics as:
mount --bind /srv/media /home/bob/assets/shared
The bound directory is the source directory. There's no copy, no union, no layering in the OverlayFS sense. You're looking at the same inodes through a different path. Writes go to the source. Deletes hit the source. stat returns the source's metadata.
The only things the bind can add:
readonly— likemount --bind -o roacl— choose whose permission rules govern access (see §6)
3.2 VfsBind interface
interface VfsBind {
/** Unique bind id (slug or uuid) */
id: string;
/** Source: what to mount */
source: {
mount: string; // existing mount name
subpath?: string; // optional sub-tree of the source
};
/** Target: where to mount it */
target: {
mount: string; // host mount name (e.g. "home", "www")
path: string; // path inside the host (e.g. "assets/shared")
};
/** Options */
readonly?: boolean; // enforce read-only (default: false)
/**
* ACL strategy:
* "source" → source mount's ACL governs (default, like bind mount)
* "inherit" → target/host ACL governs; source accessed raw
*/
acl?: "source" | "inherit";
}
3.3 Storage
Binds are stored in config/vfs-binds.json (system-wide) and optionally in per-user vfs-settings.json under a binds key:
// config/vfs-binds.json (system-wide)
[
{
"id": "shared-media",
"source": { "mount": "media" },
"target": { "mount": "home", "path": "assets/shared" },
"readonly": true,
"acl": "source"
}
]
// vfs-settings.json (per-user, extends existing format)
{
"owner": "aaa-bbb-ccc",
"acl": [ ... ],
"binds": [
{
"id": "alice-collab",
"source": { "mount": "user:aaa-bbb-ccc", "subpath": "public" },
"target": { "path": "collabs/alice" },
"acl": "source"
}
]
}
4. Resolution Algorithm
When a request arrives for mount:/some/deep/path:
1. resolveMount(mount, userId) → host IMount (unchanged)
2. NEW: resolveBind(hostMount, subpath, visited = [])
- Walk the bind table for binds targeting this host mount
- Find the longest-prefix match:
bind.target.path is a prefix of subpath
- If match found:
remainingPath = subpath.slice(bind.target.path.length)
return { bind, remainingPath }
- If no match: proceed with host VFS as today
3. If bind matched:
- Guard: if source mount already in `visited`, throw ELOOP
- Push source mount onto visited
- Resolve source mount → source IMount
- sourceSubpath = join(bind.source.subpath || '', remainingPath)
- RECURSE: resolveBind(sourceMount, sourceSubpath, visited)
(handles nested binds — a bind inside a bind)
- createVFS(sourceMount, sourceOwner, callerId)
- If bind.readonly → wrap in ReadOnlyVfsProxy
- ACL: see §6
Prefix-match example
Request: home:/assets/shared/photos/2024/img.jpg
Bind: target.path = "assets/shared" → source.mount = "media"
────────────────────────────
Remaining: photos/2024/img.jpg
Resolved to: media:/photos/2024/img.jpg
5. readdir Merging (Virtual Entries)
When listing a directory that contains a bind mount point, the host listing needs to include the bind as a directory entry:
host readdir("assets/") returns: [own-file.txt, readme.md]
bind target.path = "assets/shared"
merged result: [own-file.txt, readme.md, shared/]
^^^^^^ virtual entry from bind
Implementation
async readdir(path: string): Promise<INode[]> {
const hostEntries = await hostVfs.readdir(path);
// Find binds whose target.path is a direct child of `path`
const bindEntries = binds
.filter(b => {
const rel = b.target.path.replace(path + '/', '');
return b.target.path.startsWith(path + '/') && !rel.includes('/');
})
.map(b => ({
name: b.target.path.split('/').pop()!,
path: b.target.path,
type: 'dir',
mime: 'inode/directory',
size: 0,
mtime: 0,
parent: path,
_bind: b.id, // tag for the UI
}));
return [...hostEntries, ...bindEntries];
}
Edge case: host dir already exists at bind path
Just like mount --bind on Linux — if the host has a real directory at assets/shared/, the bind shadows it. The host's contents are invisible while the bind is active. When the bind is removed, the original contents reappear.
This is important: we don't merge host + source contents. The bind completely replaces the path.
6. ACL Interaction
6.1 acl: "source" (default)
The source VFS's own ACL governs. The bind is a portal — you must have permissions in the source mount.
AclVfsClientis created with the source ownerIdresourceChain()operates on source paths (relative to source root)- The host ACL does not apply to bind contents
- The source owner's
vfs-settings.jsondetermines who can read/write
Real-world analogy: You mount --bind Alice's /srv/shared into Bob's home. Accessing files checks Alice's POSIX permissions, not Bob's.
When to use: Cross-user sharing, system mounts, anything where the source owner should control access.
6.2 acl: "inherit"
The target/host ACL governs the bound path. The source VFS is accessed without ACL wrapping (raw LocalVFS).
resourceChain()uses the host ownerId and the full host path (including the bind prefix)- Host owner can set ACL rules on
assets/shared/*in their ownvfs-settings.json - Source VFS permissions are ignored
Real-world analogy: You mount --bind a shared volume into a container. The container's own permission model governs access; the underlying volume has no opinion.
When to use: Admin-managed shared content where one ACL tree should cover everything. A www mount that binds in sub-sites — the www ACL governs all of them.
6.3 ACL edge cases
Scenario A: Bob mounts Alice's public folder (acl: "source")
Bind: source=user:alice:/public → target=user:bob:/collabs/alice
ACL: "source"
Bob accesses: user:bob:/collabs/alice/photo.jpg
Resolves to: user:alice:/public/photo.jpg
ACL check: Does Bob have 'read' on vfs:alice:/public/photo.jpg?
→ Checked against Alice's vfs-settings.json
→ Alice must have granted Bob read access
Scenario B: Admin binds templates into user home (acl: "inherit")
Bind: source=templates:/ → target=home:/templates acl: "inherit"
User accesses: home:/templates/invoice.docx
Resolves to: templates:/invoice.docx
ACL check: Does user have 'read' on vfs:<userId>:/templates/invoice.docx?
→ Checked against user's own vfs-settings.json
→ User is owner of their home, so they have '*' on '/' → allowed
Scenario C: Third-party accesses a bind path
Bind: source=media:/ → target=user:bob:/assets/shared acl: "source"
Carol (no grants) accesses: user:bob:/assets/shared/photo.jpg
Resolves to: media:/photo.jpg
ACL check: Does Carol have 'read' on media's ACL?
→ media mount's vfs-settings.json decides
→ If media grants public read: allowed
→ If media requires explicit grants: denied
Scenario D: Write through a readonly bind
Bind: source=media:/ → target=home:/media readonly: true
User writes: home:/media/new-file.txt
Result: EACCES — ReadOnlyVfsProxy rejects before ACL is even checked
(structural constraint, not permission)
Scenario E: Permission granted on host path, but bind uses source ACL
Bind: source=user:alice:/docs → target=user:bob:/shared/alice acl: "source"
Bob's vfs-settings.json grants Carol read on /shared/*
Carol accesses: user:bob:/shared/alice/secret.txt
Resolves to: user:alice:/docs/secret.txt
ACL check: Alice's ACL (source) — Bob's grant is irrelevant
Result: Carol is denied unless Alice also granted her access
This is the most important edge case to document. With acl: "source", host-side grants do NOT propagate into binds. The bind is an ACL boundary. This matches mount --bind semantics — permissions come from the mounted filesystem, not the mount point.
6.4 readonly: true
A ReadOnlyVfsProxy wraps the resolved VFS and throws EACCES on any write/delete/mkdir/rename/copy operation. This is independent of ACL — it's a hard structural constraint on the bind.
Applied after ACL resolution: even if the ACL would allow write, readonly blocks it.
7. Real-World Edge Cases (mount --bind Parity)
7.1 Rename/move across bind boundary
User renames: home:/my-file.txt → home:/assets/shared/my-file.txt
^^^^ host FS ^^^^ bind (source = media)
This is a cross-device rename in Linux terms. rename(2) returns EXDEV. Our VFS must do the same:
- Detect that source and destination resolve to different underlying VFS instances
- Return a clear error:
"Cannot move across bind boundaries. Copy + delete instead." - Frontend should catch this and offer a copy-then-delete flow
7.2 Hard links across bind boundary
Not applicable — our VFS doesn't support hard links. But symlinks (if ever added) should NOT follow across bind boundaries by default (same as mount --bind).
7.3 stat on the bind mount point itself
stat("assets/shared") — what does this return?
Two options:
- Option A (Linux-like): Return the
statof the source root dir (or source subpath). This is whatmount --binddoes — the mount point takes on the stat of the mounted thing. - Option B (synthetic): Return a synthetic
INodewith_bindmetadata.
Decision: Option A. The bind mount point behaves as the source root. stat returns the source's mtime, size, etc. The _bind flag is only added during readdir so the UI can show an icon, but stat is transparent.
7.4 Deleting the bind mount point from the host
rm -rf home:/assets/shared — what happens?
This should NOT delete the source. It should unmount (remove the bind).
In Linux, rm -rf /mnt/bind-target would delete the source's contents (dangerous!). We should be safer:
rmdiron a bind mount point → error:"Cannot delete bind mount point. Use unbind."- Files inside the bind:
rmworks normally against the source (unless readonly)
7.5 Creating a file/dir at the exact bind path when no bind exists
No issue — if no bind matches, the path is a regular host path. If a bind is later created targeting that path, it shadows the host content (existing host dir becomes invisible while the bind is active).
7.6 Nested binds (bind inside a bind)
Bind 1: source=media:/ → target=home:/media
Bind 2: source=archive:/ → target=media:/old
User accesses: home:/media/old/2020/photo.jpg
Resolution chain:
home:/media/old/2020/photo.jpg→ Bind 1 matches →media:/old/2020/photo.jpgmedia:/old/2020/photo.jpg→ Bind 2 matches →archive:/2020/photo.jpgarchivehas no binds →LocalVFSresolves2020/photo.jpg
This works because resolveBind() is recursive with cycle detection (visited set). Max depth = 8.
7.7 Two binds with overlapping target paths
Bind A: source=media:/ → target=home:/assets
Bind B: source=archive:/ → target=home:/assets/old
home:/assets/old/file.txt — which bind wins?
Longest prefix match: Bind B (assets/old) is more specific than Bind A (assets). Bind B wins. This matches Linux mount semantics — more specific mount points shadow less specific ones.
home:/assets/photos/file.txt → Bind A wins (only match).
7.8 Bind source doesn't exist or is offline
Bind: source=remote-nas:/ → target=home:/nas-backup
remote-nas mount is unreachable
readdiron the host dir containing the bind: still showsnas-backup/as a virtual entry- Any operation inside the bind: returns
EIOorENOENTwith message:"Bind source 'remote-nas' is unavailable" - The bind doesn't disappear — it's still configured. Like an unmounted disk in
/etc/fstab.
Shadowing example
Host readdir("assets/"): [shared/, photos/, readme.md]
Bind target = "assets/shared" → source = media
Result: [shared/ (_bind:"shared-media"), photos/, readme.md]
^^^^^^ host's 'shared' dir is shadowed by the bind
The host's assets/shared/ directory still exists on disk — it's just invisible while the bind is active.
9. ACL Cascade Diagram
┌──────────────────────────────┐
│ Request arrives at │
│ host-mount:/some/bind/path │
└──────────────┬───────────────┘
│
resolveBind(path) → match?
│ │
no yes
│ │
┌───────▼──────┐ ┌───────▼───────────────┐
│ Normal flow │ │ acl mode? │
│ host ACL │ └──┬───────────────┬────┘
└──────────────┘ │ │
acl:"source" acl:"inherit"
│ │
┌───────────▼──┐ ┌────────▼──────────┐
│ Source mount │ │ Host ACL checks │
│ AclVfsClient │ │ path = full host │
│ source owner │ │ path through bind │
│ source paths │ │ │
└──────┬───────┘ │ Source accessed │
│ │ raw (no ACL wrap) │
┌──────▼───────┐ └────────┬───────────┘
│ LocalVFS │ │
│ source root ◄────────────┘
└──────────────┘
readonly: true? → wrap either path in ReadOnlyVfsProxy
10. CLI Interface
The VFS should behave like any other filesystem — including a CLI. This enables scripting, future FUSE integration, and admin automation.
10.2 pmvfs resolve (debug tool)
Shows the full resolution path — invaluable for debugging nested binds:
$ pmvfs resolve home:/assets/shared/photos/2024/img.jpg
home:/assets/shared/photos/2024/img.jpg
├─ bind "shared-media" (acl: source, readonly: true)
├─ → media:/photos/2024/img.jpg
└─ → LocalVFS /srv/data/media/photos/2024/img.jpg
10.3 Implementation
The CLI is a thin wrapper over the HTTP API (/api/vfs/*). Uses the same auth tokens as the web client. This means:
- No special server-side code — CLI and web share the same endpoints
- Auth via
PMVFS_TOKENenv var or~/.pmvfs/auth.json - Output formats: human-readable (default), JSON (
--json), tab-separated (--tsv)
10.4 Future: FUSE bridge
When FUSE/WinFSP support is added, the CLI becomes the management tool:
# Mount the entire VFS namespace as a drive
pmvfs fuse mount Z: # Windows (WinFSP)
pmvfs fuse mount /mnt/pmvfs # Linux (FUSE)
# The bind resolution happens inside the FUSE driver
# User sees: Z:\home\assets\shared\photos\ (backed by media mount)
The FUSE driver would use the same resolveBind() algorithm. The CLI is the control plane; FUSE is the data plane.
11. Impact Analysis
Server changes
| File | Change |
|---|---|
vfs/fs/VFS.ts |
Add VfsBind interface, BindManager class |
api/vfs.ts |
After resolveMount(), add resolveBind() step. Modify createVFS for source resolution. Modify readdir for merge/shadow. Cross-bind rename detection. |
api/vfs-routes.ts |
Add routes for bind CRUD: GET/POST/DELETE /api/vfs/binds/{mount} |
api/acl-helpers.ts |
Handle acl: "source" | "inherit" selection |
config/ |
New vfs-binds.json config file |
@polymech/acl changes
| File | Change |
|---|---|
vfs/vfs-acl.ts |
VfsSettings type: add optional binds: VfsBind[] |
vfs/AclVfsClient.ts |
No change — bind resolution is a layer above |
Frontend changes
| File | Change |
|---|---|
AclEditor.tsx |
Show bind badge on bound entries; block ACL editing if acl: "source" (redirect to source) |
FileBrowser / useVfsAdapter |
Handle _bind flag; show mount icon; intercept cross-bind rename with error UX |
client-acl.ts |
Add bind CRUD API calls |
CLI (new)
| File | Change |
|---|---|
cli/pmvfs.ts |
New CLI entry point |
cli/commands/ |
bind.ts, ls.ts, cat.ts, cp.ts, mv.ts, rm.ts, mkdir.ts, stat.ts, resolve.ts, acl.ts |
Wire format
Existing: GET /api/vfs/ls/home/assets/shared/photos
^^^^^^^^^^^^^^ host path
With binds: same URL, server resolves transparently.
No client-side path change needed.
The token encodes: who granted it, what permissions, expiry. Eliminates the need for Alice to know Bob's UUID upfront.
14.3 WebDAV surface
A thin WebDAV adapter over the bind-aware VFS would let users mount their space natively in Windows Explorer / macOS Finder / Linux file managers:
net use Z: https://service.polymech.info/webdav/home
Binds would be transparent — the user sees Z:\assets\shared\ backed by the media mount.
15. Open Questions
-
Copy-on-write mode? When
acl: "source"andreadonly: false, writes hit the source. Should we support a COW mode where writes go to the host instead (overlay-style)? -
Search indexing: Should search traverse into binds? Probably yes for queries, configurable for indexing (to avoid double-indexing the same physical content via multiple bind paths).
-
Per-user binds in multi-tenant: Should regular users create binds (mounting shared system mounts into their home), or admin-only? Likely: users can bind sources they already have
listpermission on. -
Delete semantics:
rmon files inside a bind = deletes from source (unless readonly).rmdiron the bind mount point itself = error.unbindis a separate operation. -
Disk usage / quota: If
mediais bound into 5 user homes,duon each home would count the media content 5 times. Need a way to exclude bind paths from quota calculations.
16. Implementation Status
What's done ✅
| Feature | Status | Location |
|---|---|---|
VfsBind interface + BindManager |
✅ Done | vfs/fs/VFS.ts |
resolveBind() with recursive resolution + cycle detection |
✅ Done | api/vfs.ts |
config/vfs-binds.json with 3 binds (root-assets, home-assets, machines-firmware) |
✅ Done | config/vfs-binds.json |
resolveVfsContext() — unified context resolution |
✅ Done | api/vfs-context.ts |
All handlers migrated to resolveVfsContext() |
✅ Done | LsRoot, Ls, Read, Write, Delete, Upload, Mkdir, Get, Stat, Compress |
getVirtualBindEntries() — virtual dir injection in ls |
✅ Done | api/vfs.ts |
mergeVirtualEntries() — dedup real + virtual |
✅ Done | api/vfs.ts |
rebaseBindPaths() — re-prefix paths from source→host context |
✅ Done | api/vfs.ts |
vfs-config.ts — shared config module + test injection API |
✅ Done | api/vfs-config.ts |
Bare UUID mount resolution (/api/vfs/ls/<uuid>) |
✅ Done | api/vfs.ts, db-acl-vfs.ts |
db-acl-vfs.ts deduplicated — uses shared vfs-config.ts |
✅ Done | serving/db/db-acl-vfs.ts |
VfsContext.requestedSubpath for bind path correction |
✅ Done | api/vfs-context.ts |
| E2E tests — 6/6 nested mount tests passing | ✅ Done | __tests__/vfs-nested.e2e.test.ts |
Key modules
api/vfs-config.ts ← Single source of truth for mount/bind config
getMounts(), getBinds(), setMounts(), setBinds(), resetVfsConfig()
api/vfs-context.ts ← resolveVfsContext() — one call, full context
api/vfs.ts ← Handlers, resolveBind(), rebaseBindPaths(),
getMountManager(), getBindManager(), resetManagers()
serving/db/db-acl-vfs.ts ← ACL backend — imports getMounts() from vfs-config.ts
Test injection API
import { setMounts, setBinds, resetVfsConfig } from '../api/vfs-config.js';
import { resetManagers } from '../api/vfs.js';
beforeAll(() => {
setMounts([
{ name: 'test-root', type: 'fs', path: tmpDir },
{ name: 'test-src', type: 'fs', path: srcDir },
]);
setBinds([
{ id: 'test-bind', source: { mount: 'test-src' },
target: { mount: 'test-root', path: 'linked' } }
]);
resetManagers(); // clear cached MountManager/BindManager
});
afterAll(() => { resetVfsConfig(); resetManagers(); });
Security notes ⚠️
- Path normalization —
sanitizeSubpath()runs before VFS operations. Bind prefix matching uses normalized paths. startsWithprefix matching — bind paths come from admin config only (trusted). User-created binds (future) must validate throughsanitizeSubpath()at creation time.- ACL boundary — with
acl: "source", host ACL is bypassed. Bind mount point visibility is information leakage (acceptable, noted for hardening). - Cycle detection —
resolveBind()tracks visited mounts in aSet<string>and throwsELOOP. Max depth = 8.
17. TODO
Priority 1 — readonly support
- Implement
ReadOnlyVfsProxywrapper - Apply when
bind.readonly === trueafter VFS creation - Test: write through readonly bind → EACCES
Priority 2 — Cross-bind operations
handleVfsRename— cross-bindEXDEVdetectionhandleVfsCopy— cross-bind copy support
Priority 3 — ACL inherit mode
- Implement: when
acl: "inherit", create VFS with host ownerId but source mount's LocalVFS - Test: host ACL governs access to bind content
Priority 4 — Frontend
FileBrowsershows bind icon for virtual entriesAclEditorshows bind badge, blocks ACL edit ifacl: "source"- Cross-bind rename error UX
Priority 5 — Admin API
GET /api/vfs/binds— list all bindsPOST /api/vfs/binds— create bind (admin only)DELETE /api/vfs/binds/:id— remove bind- Input validation: sanitize bind paths at creation
Priority 6 — CLI + FUSE
pmvfsCLI entry point- Basic commands:
ls,cat,bind create/ls/rm - FUSE bridge (future)