mono/packages/acl/docs/vfs-layers.md

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

  1. MountManager is a flat array — no nesting concept.
  2. resolveMount() returns a single { mount, ownerId } — one VFS driver per request.
  3. AclVfsClient.#guard() checks resourceChain(ownerId, subpath) against a single vfs-settings.json.
  4. INode.path is always relative to the mount root — no cross-mount references.
  5. Frontend (FileBrowser, AclEditor) uses mount:path strings 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 — like mount --bind -o ro
  • acl — 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.

  • AclVfsClient is created with the source ownerId
  • resourceChain() operates on source paths (relative to source root)
  • The host ACL does not apply to bind contents
  • The source owner's vfs-settings.json determines 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 own vfs-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

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 stat of the source root dir (or source subpath). This is what mount --bind does — the mount point takes on the stat of the mounted thing.
  • Option B (synthetic): Return a synthetic INode with _bind metadata.

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:

  • rmdir on a bind mount point → error: "Cannot delete bind mount point. Use unbind."
  • Files inside the bind: rm works 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:

  1. home:/media/old/2020/photo.jpg → Bind 1 matches → media:/old/2020/photo.jpg
  2. media:/old/2020/photo.jpg → Bind 2 matches → archive:/2020/photo.jpg
  3. archive has no binds → LocalVFS resolves 2020/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
  • readdir on the host dir containing the bind: still shows nas-backup/ as a virtual entry
  • Any operation inside the bind: returns EIO or ENOENT with 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_TOKEN env 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

  1. Copy-on-write mode? When acl: "source" and readonly: false, writes hit the source. Should we support a COW mode where writes go to the host instead (overlay-style)?

  2. 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).

  3. 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 list permission on.

  4. Delete semantics: rm on files inside a bind = deletes from source (unless readonly). rmdir on the bind mount point itself = error. unbind is a separate operation.

  5. Disk usage / quota: If media is bound into 5 user homes, du on 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 ⚠️

  1. Path normalizationsanitizeSubpath() runs before VFS operations. Bind prefix matching uses normalized paths.
  2. startsWith prefix matching — bind paths come from admin config only (trusted). User-created binds (future) must validate through sanitizeSubpath() at creation time.
  3. ACL boundary — with acl: "source", host ACL is bypassed. Bind mount point visibility is information leakage (acceptable, noted for hardening).
  4. Cycle detectionresolveBind() tracks visited mounts in a Set<string> and throws ELOOP. Max depth = 8.

17. TODO

Priority 1 — readonly support

  • Implement ReadOnlyVfsProxy wrapper
  • Apply when bind.readonly === true after VFS creation
  • Test: write through readonly bind → EACCES

Priority 2 — Cross-bind operations

  • handleVfsRename — cross-bind EXDEV detection
  • handleVfsCopy — 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

  • FileBrowser shows bind icon for virtual entries
  • AclEditor shows bind badge, blocks ACL edit if acl: "source"
  • Cross-bind rename error UX

Priority 5 — Admin API

  • GET /api/vfs/binds — list all binds
  • POST /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

  • pmvfs CLI entry point
  • Basic commands: ls, cat, bind create/ls/rm
  • FUSE bridge (future)