675 lines
26 KiB
Markdown
675 lines
26 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```jsonc
|
|
// config/vfs-binds.json (system-wide)
|
|
[
|
|
{
|
|
"id": "shared-media",
|
|
"source": { "mount": "media" },
|
|
"target": { "mount": "home", "path": "assets/shared" },
|
|
"readonly": true,
|
|
"acl": "source"
|
|
}
|
|
]
|
|
```
|
|
|
|
```jsonc
|
|
// 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
|
|
|
|
```ts
|
|
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
|
|
|
|
### 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 `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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```typescript
|
|
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 normalization** — `sanitizeSubpath()` 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 detection** — `resolveBind()` 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)
|