Files
eepp/docs/articles/vfs.md
T
Martín Lucas Golini 0e7e12ba76 fix(vfs): data safety, SSH policy and liveness fixes from the pre-merge review
Data safety (VFS):
- Copies write a temporary sibling and replace the destination only
  once it is complete (as writeAll() already did): a failed overwrite
  kept nothing of the old destination, and copying onto a hard link to
  the source truncated the source itself. A destination symlink is
  written through, and one that resolves to the source fails as the
  same file.
- Moving a symlink to another file system is refused: the fallback copy
  followed it and then removed the copied entries through the link,
  emptying its target.
- Saves keep the exact mode of the file they replace: the temporary was
  created with it, but filtered through the umask.

Deadlocks and blocking:
- writeAll( createParents ) waited on makeDirs() queued on its own
  orchestration pool: one thread deadlocked at once, two with two
  concurrent saves. Orchestration jobs now create parents directly.
- Copies and moves run on their own pool (Config::transferThreads), so
  long transfers no longer delay saves, reads and recursive removals.
- Local watches are registered and removed on the I/O pool: efsw and the
  directory checks touch the filesystem, which blocked the UI thread on
  slow mounts.

SSH security:
- "@revoked" known_hosts keys are rejected; they were skipped as
  unknown, so StrictHostKeyChecking accept-new trusted them.
- Sessions are shared only between configurations with the same
  security policy (known hosts, host key checking, algorithms,
  authentication settings), not just the same destination: a strict
  alias reused a session a permissive one had accepted.
- A configured algorithm restriction that permits nothing this libssh2
  supports fails the connection instead of using libssh2's defaults.

SSH liveness:
- Operations submitted to a failed or closed connection fail with
  ConnectionLost instead of staying pending forever.
- ssh-agent requests run on a worker thread (SSHAgent, Unix-domain
  sockets), with libssh2_userauth_publickey() and a sign callback that
  waits without blocking: an agent waiting for a hardware key touch no
  longer stalls every other session. Windows keeps libssh2's agent.
- Connecting no longer holds a helper thread while the user answers a
  host key or password prompt: the setup continues from the session's
  ready task, one ProxyJump hop at a time. Jump hosts only need to
  authenticate, not to offer SFTP.
- Idle connections close after SFTPProviderConfig::idleTimeout. The old
  check compared use_count() with 1 and never passed: connections now
  track their users (open handles, pumps, jump children).
- A remote watch agent that never answers falls back to polling after
  5 seconds; the directory listing waited for it indefinitely.

Core:
- CancellationSource::cancel() keeps the shared state alive while its
  callbacks run: a callback destroying the source freed it mid-cancel
  (heap use after free under AddressSanitizer).
- MainThreadExecutor::shutdown() detaches the executor from its
  dispatcher node and drops queued and later functions.
- String::icompare(): case-insensitive comparison without allocations.

VFSDirectoryModel (UI thread):
- Names compare with String::icompare() instead of lowercased copies,
  which made every sort, merge and insertion allocate (about 2x slower
  when sorting).
- Sorting by type asks the type provider once per entry instead of
  twice per comparison.
- The case-insensitive plain text filter keeps a search table instead
  of lowercasing every name on each row rebuild.
- Watch events update the visible rows in place instead of rebuilding
  them from every entry per event.

Drag and drop:
- The X11 drag source showed the pending badge over the source window
  for every application, which broke the X11 drag and drop integration
  tests in CI. DragOffer::sourceHandlesDrops now asks for it; eefm sets
  it, since it completes those drops as its own drag.

eefm:
- Shutdown destroyed the main thread executor before the VFS and the
  thread pool while continuations still held it: closing during a
  transfer could dispatch through freed memory. Transfers are cancelled,
  the VFS is destroyed, the executor is detached, and only then are the
  scene, its pool and the executor destroyed.
- Remote files opened locally were downloaded to
  /tmp/eefm/<scheme>/<host>/<name>: files with the same name from other
  folders, users or ports overwrote each other, in a directory shared by
  every user. They now go to a per-file folder (a hash of the complete
  URI) in the user's cache.

Every fix comes with a regression test.
2026-10-15 20:33:13 -03:00

226 lines
11 KiB
Markdown

# Virtual File System (eepp-vfs)
## Introduction
`eepp-vfs` is an optional static module that gives applications one asynchronous, URI-based API
for local files, packed resources and remote servers:
| Scheme | Provider | Notes |
| --- | --- | --- |
| `file://` | `LocalProvider` | Native filesystem (POSIX and Win32). |
| `res://` | `ResourceProvider` | Read-only view of every open `Pack` (`System::PackFileIndex`). |
| `sftp://` | `SFTPProvider` | Built by default (`--without-vfs-sftp` disables it), libssh2 based, honors `~/.ssh/config`. |
| `mem://` | `MemoryProvider` | In-memory filesystem, mainly for tests. |
The module builds on the [async core](async_core.md), generic primitives that live in eepp core and
are useful on their own:
| Header | Type |
| --- | --- |
| `<eepp/core/result.hpp>` | `Result<T, E>`, `Ok()`, `Err()` |
| `<eepp/core/task.hpp>` | `Task<T>`, `TaskSource<T>`, `makeReadyTask()` |
| `<eepp/core/cancellationtoken.hpp>` | `CancellationSource`, `CancellationToken`, `CancellationRegistration` |
| `<eepp/core/executor.hpp>` | `Executor`, `InlineExecutor` (`ThreadPool` is an `Executor` and has `submit()`) |
| `<eepp/scene/mainthreadexecutor.hpp>` | `MainThreadExecutor`: runs continuations on the UI thread |
| `<eepp/core/uri.hpp>` | `URI` (formerly `Network::URI`, which remains an alias) |
Link the module with `eepp_module_vfs_add()` in Premake and include `<eepp/vfs.hpp>`. The UI model
lives in `<eepp/vfs/ui/vfsdirectorymodel.hpp>` and is not part of the aggregate header.
## Tasks, results and threads
Every VFS operation returns `VFSTask<T>`, an alias of `Task<Result<T, VFSError>>`:
```cpp
auto& vfs = EE::VFS::instance();
MainThreadExecutor ui( sceneNode );
vfs.stat( URI( "sftp://makyo/home/black/notes.txt" ) )
.thenOn( ui, []( VFSResult<FileInfo>&& info ) {
if ( info )
label->setText( info.value().name );
else
label->setText( info.error().toString() );
} );
```
Rules:
- VFS methods never block and may be called from any thread.
- Tasks resolve on VFS threads. `then()` runs where the task resolves; `thenOn( executor, ... )`
always goes through the executor. Touch UI or model state only from `thenOn( uiExecutor, ... )`.
- A `Task` has one consumer: continuations consume it (`std::move( task ).then( ... )`).
A continuation returning another task is flattened.
- If a producer is destroyed without resolving, its task chain becomes `Abandoned` instead of
hanging. That is an infrastructure bug (reported by `TaskDiagnostics`), never a domain outcome.
- Cancellation is cooperative: pass `CancellationToken`s; cancelled operations complete with
`VFSErrorCode::Cancelled`.
- `Task::wait()`/`get()` block. They exist for worker-thread adapters (for example `IOStreamVFS`)
and tests; never use them on the UI thread.
Errors are typed (`VFSErrorCode`): callers branch on codes such as `NotFound`,
`PermissionDenied`, `ChangedExternally` or `HostKeyRejected`, never on errno or libssh2 values.
## Files and directories
```cpp
// Incremental listing: batches with an explicit end-of-directory flag.
vfs.openDirectory( uri ); // VFSTask<DirectoryHandle>; then handle.read( 256 ) per batch
vfs.listAll( uri ); // convenience: the whole directory
vfs.readAll( uri ); // VFSTask<std::vector<Uint8>>
vfs.writeAll( uri, data ); // atomic by default, returns the new FileInfo
vfs.makeDirs( uri );
vfs.remove( uri, RemoveOptions{ true } ); // recursive
vfs.rename( from, to, RenameOptions{ true } );
vfs.canonicalize( uri ); // resolves symlinks/aliases; URIs are never canonicalized implicitly
```
`FileHandle` uses explicit offsets (`read( offset, span )`, `write( offset, span )`); `FileCursor`
adds a sequential position. `IOStreamVFS` exposes a VFS file as a blocking `System::IOStream` for
existing decoders running on worker threads.
`FileInfo` is a plain value. Unknown metadata stays unknown (`std::optional` accessors). It does
not store its own URI, because listings hold many entries and the caller always knows the URI it
listed: use `info.uriIn( directory )` for a listed entry (`VFSDirectoryModel::entryURI( row )` in
the model). `FileInfo::revision` is an opaque token used for conflict detection:
```cpp
WriteOptions options;
options.expectedRevision = lastKnown.revision; // from a previous stat/writeAll
vfs.writeAll( uri, text, options ); // fails with ChangedExternally if modified
```
Atomic writes go to a sibling temporary file, are flushed and renamed over the destination; a
symlinked destination is written through to its target. Providers report what they can do through
`capabilities( uri )` (for example `AtomicReplace`, `Fsync`, `Permissions`).
### Copy and move
Recursive and cross-provider transfers are orchestrated by the VFS, not by providers:
```cpp
Transfer transfer = vfs.copy( URI( "sftp://makyo/srv/www" ), URI::fromFSPath( "/tmp/www" ) );
auto progress = transfer.onProgress( []( const TransferProgress& p ) { ... }, &uiExecutor );
transfer.completion().thenOn( uiExecutor, []( VFSResult<TransferResult>&& r ) { ... } );
transfer.cancel();
```
Moves use a native rename when both sides are on the same provider/authority and fall back to copy
plus removal of the entries that were completely copied; a source is never removed after a partial
transfer.
File copies are written to a temporary sibling before publication. `Fail` and `Skip` also apply
when a destination appears while the copy is running. The `Overwrite` policy requires
`AtomicReplace`; otherwise the transfer returns `Unsupported` and preserves the old contents.
Creating a file with `Fail` or `Skip` requires `Rename`. Moving a symlink through the copy fallback
is unsupported; native same-filesystem renames still move the link itself.
## Watching
```cpp
vfs.watch( uri, WatchOptions{ /*recursive*/ true } )
.thenOn( ui, [this]( VFSResult<WatchHandle>&& watch ) {
mWatch = std::move( watch ).value();
mWatch.onEvents( ui, [this]( WatchEventBatch&& batch ) { ... } );
} );
```
Events are relative to the watched root and arrive in coalesced batches through the executor you
choose. `RescanRequired` means continuity was lost (missed native events, reconnection, overflow):
re-list the directory. `WatchHandle::status()` reports `Active`, `Polling`, `Reconnecting` or
`Failed`, so a broken remote watch never looks healthy.
Backends:
- `file://`: efsw native watching; directories efsw reports as remote (NFS, SMB) use efsw's
generic polling watcher.
- `sftp://`: `efsw-agent --stdio` runs on the server through an exec channel of the same SSH
connection (one agent per connection, many watches). When the agent dies or the connection drops,
watches report `Reconnecting`, re-register and emit `RescanRequired`. If the agent is not
installed they fall back to polling over SFTP. SFTP itself never depends on the agent.
- Anything else: polling through the VFS (`PollingWatchBackend`).
To establish a consistent view, watch first, list second, then apply the events that arrived during
the listing; `VFSDirectoryModel` does exactly that.
## VFSDirectoryModel
A `UI::Models::Model` for one directory of any scheme:
```cpp
auto model = VFSDirectoryModel::New( uiExecutor );
tableView->setModel( model );
model->setURI( URI( "sftp://makyo/home/black" ) );
```
It enumerates incrementally (the first rows appear after the first batch), discards results of
previous navigations, stays live through watches (row inserts/deletes keep the selection), and
exposes `loadState()`, `error()` and `watchStatus()`.
Columns are Name, Size, Modified, Type, Permissions, Owner and Group; cells carry a per-column CSS
class (`vfs_size`, ...). Applications customize the text with `setTypeProvider()`,
`setTimeFormatter()` and `setItemCountFormatter()`. With `Config::countDirectoryItems` (or
`setCountDirectoryItems()`), directories are counted in the background and the Size column shows
"N items". `setNameFilter( pattern, mode, caseSensitive )` filters rows by name with unanchored
`PlainText`, `Glob` or `RegEx` patterns, like Dolphin's filter bar.
## SFTP
SFTP is built by default (disable it with `--without-vfs-sftp`, Premake 4 or 5; it is never built
for emscripten). This compiles the vendored libssh2
(`src/thirdparty/libssh2`) against eepp's mbedTLS, or OpenSSL when `--with-openssl` is used.
`sftp://host/path` resolves `host` through OpenSSH configuration exactly like `ssh host` would:
`~/.ssh/config` and the system `ssh_config`, `Host`/`Match` (all client criteria), `Include`,
first-value-wins, `HostName`, `Port`, `User`, `IdentityFile`, `IdentityAgent`, `IdentitiesOnly`,
`ProxyJump`, `ProxyCommand`, `UserKnownHostsFile`, `StrictHostKeyChecking`, `HostKeyAlias`,
algorithm lists, hostname canonicalization and the final re-parse pass. A user or port in the URI
overrides the configuration. Passwords are rejected in URIs. `SSHConfigEvaluator` is usable on its
own and is tested against `ssh -G`.
Ed25519 identity files and host keys and the `curve25519-sha256` key exchange are supported with
both crypto backends. mbedTLS does not implement them, so the vendored libssh2 gets them from
Monocypher (`src/thirdparty/monocypher`, `src/thirdparty/libssh2/src/mbedtls_ed25519.c`).
Host keys follow `StrictHostKeyChecking`. Unless `HostKeyAlgorithms` is configured, the host key
algorithms that known_hosts already has keys for are preferred, as OpenSSH does. Changed keys are always refused. For unknown keys with
`ask`, the provider asks an application `SSHHostKeyVerifier`; secrets (passwords, passphrases,
keyboard-interactive answers) come from an `SSHAuthenticationHandler`. Both return tasks, so the
SSH I/O thread never blocks while the user answers. Accepted keys are appended atomically to the
user's known_hosts file.
Authentication order: ssh-agent, identity files (asking for passphrases), keyboard-interactive,
password, filtered by `PreferredAuthentications` and the `*Authentication` options.
Connections are pooled per effective configuration and shared by every operation and watch. All
sessions are driven by one non-blocking I/O thread. `posix-rename@openssh.com` (atomic replace),
`fsync@openssh.com` and `statvfs@openssh.com` are detected per server.
### efsw-agent
`efsw-agent` is part of efsw (`src/agent/efsw-agent.cpp`, protocol in
`include/efsw/agent_protocol.hpp`). It has no eepp dependency and only reports changes. Install it
on the server's `PATH`, or set `SFTPProviderConfig::watchAgentCommand`. The eepp build produces
`bin/efsw-agent` for the build host.
### Known limitations
- FIDO security-key identity files (`ssh-keygen -t ed25519-sk` / `ecdsa-sk`) are not supported
with either crypto backend: signing needs the hardware token (libfido2), which libssh2 leaves to
the application. Such keys can be used through ssh-agent, which signs on the token's behalf
(untested).
- `Match localnetwork` is not implemented on Windows.
- Jump hosts use their own `ssh_config` entries, but their own `ProxyJump`/`ProxyCommand` are
ignored (the chain comes from the destination).
- Operations on one SSH connection are serialized; a long transfer delays metadata requests on the
same connection.
- The efsw-agent is not deployed automatically.
## Resources (`res://`)
`System::PackFileIndex` (formerly `System::VirtualFileSystem`, still available as a deprecated
alias) indexes the files of every open `Pack`. `res:///fonts/a.ttf` maps to the pack path
`fonts/a.ttf`; directories are implicit. The provider is read-only and extracts files to memory
when they are opened.