vfs: overlapping move is unspecified, untested, and reports inconsistently #187

Open
opened 2026-08-06 03:12:54 +02:00 by codinget · 0 comments
Owner

Summary

An overlapping move — a destination inside the source, or a source inside the destination — is rejected by every implementation, but nothing holds them to it and they do not agree on how. NodeVFS rejects with a raw Node Error rather than a VFSError, so a caller cannot classify it and a protocol server maps it to a generic failure instead of the status the same request gets over MemoryVFS.

Surfaced while reviewing #184. Not a regression from it; it predates that work.

The coverage gap

packages/vfs/src/conformance/conformance.ts pins overlapping copy in all three directions:

  • copy(tree, tree, { overwrite: true })precondition-failed
  • copy(tree, tree/inner)precondition-failed
  • copy(tree/inner, tree, { overwrite: true })precondition-failed

There is no equivalent for move, in any direction. The move contract (packages/vfs/src/index.ts:91-95) covers a missing source and replace-rather-than-merge, and says nothing about overlap, so the suite is not omitting a documented rule — the rule was never written down.

This matters more for move than the symmetry suggests. moveFallback is copy-then-delete, so an unguarded overlapping move is the #176 heap exhaustion with a delete of the source at the end.

The bug it hides

vfsErr in packages/vfs/src/node/node.ts:66-77 maps ENOENT, EEXIST, ENOTDIR, EISDIR, ENOTEMPTY, EACCES and EPERM. POSIX rename(2) returns EINVAL for a directory moved into itself, which is not in that list, so it falls through to throw e:

$ node -e '<NodeVFS over a temp dir>; await v.mkdir("/tree"); await v.mkdir("/tree/inner"); await v.move("/tree", "/tree/inner/deep")'
rejected. isVFSError: false | code: EINVAL | name: Error

MemoryVFS rejects the same call with precondition-failed (its explicit pathsOverlap check, added in 6c66acb88 for #176), and copyFallback rejects with precondition-failed too. So the identical WebDAV MOVE answers 412 over one backend and a generic error over the other, because the raw Error fails the instanceof VFSError test in the server's handler.

This is the same shape as the NodeVFS inverted-range ERR_OUT_OF_RANGE that #175 turned up: an errno reaching a caller unmapped.

Requested direction

  1. Document overlap on the move contract, matching what copy already promises — reject with precondition-failed, in both directions, without modifying either path.
  2. Add the three conformance cases for move, mirroring the copy ones.
  3. Map EINVAL in vfsErr, or guard overlap in NodeVFS.move explicitly before calling fsp.rename. The explicit guard is probably better: EINVAL covers more than overlap, so mapping it wholesale to precondition-failed would mislabel other failures.

Step 2 is what makes step 3 fail, so they want doing together.

Worth checking the other implementations against the new cases while there — FsaVFS, and the SFTP, FTP, SMB2 and WebDAV clients, whose answer comes from a remote server and may not be precondition-failed either.

Related

  • #176 — the same overlap defect on MemoryVFS.copy, closed. This is the move half.
  • #137 — copy/move semantics; if existing replaces overwrite, the overlap rule should be stated in the same pass.
  • #184 — where this was found.
  • #186rename will need the same rule, and inherits it if it is on the contract rather than on one method.
## Summary An overlapping `move` — a destination inside the source, or a source inside the destination — is rejected by every implementation, but nothing holds them to it and they do not agree on how. `NodeVFS` rejects with a raw Node `Error` rather than a `VFSError`, so a caller cannot classify it and a protocol server maps it to a generic failure instead of the status the same request gets over `MemoryVFS`. Surfaced while reviewing #184. Not a regression from it; it predates that work. ## The coverage gap `packages/vfs/src/conformance/conformance.ts` pins overlapping `copy` in all three directions: - `copy(tree, tree, { overwrite: true })` → `precondition-failed` - `copy(tree, tree/inner)` → `precondition-failed` - `copy(tree/inner, tree, { overwrite: true })` → `precondition-failed` There is no equivalent for `move`, in any direction. The `move` contract (`packages/vfs/src/index.ts:91-95`) covers a missing source and replace-rather-than-merge, and says nothing about overlap, so the suite is not omitting a documented rule — the rule was never written down. This matters more for `move` than the symmetry suggests. `moveFallback` is copy-then-delete, so an unguarded overlapping move is the #176 heap exhaustion with a `delete` of the source at the end. ## The bug it hides `vfsErr` in `packages/vfs/src/node/node.ts:66-77` maps `ENOENT`, `EEXIST`, `ENOTDIR`, `EISDIR`, `ENOTEMPTY`, `EACCES` and `EPERM`. POSIX `rename(2)` returns `EINVAL` for a directory moved into itself, which is not in that list, so it falls through to `throw e`: ``` $ node -e '<NodeVFS over a temp dir>; await v.mkdir("/tree"); await v.mkdir("/tree/inner"); await v.move("/tree", "/tree/inner/deep")' rejected. isVFSError: false | code: EINVAL | name: Error ``` `MemoryVFS` rejects the same call with `precondition-failed` (its explicit `pathsOverlap` check, added in `6c66acb88` for #176), and `copyFallback` rejects with `precondition-failed` too. So the identical WebDAV MOVE answers 412 over one backend and a generic error over the other, because the raw `Error` fails the `instanceof VFSError` test in the server's handler. This is the same shape as the `NodeVFS` inverted-range `ERR_OUT_OF_RANGE` that #175 turned up: an errno reaching a caller unmapped. ## Requested direction 1. Document overlap on the `move` contract, matching what `copy` already promises — reject with `precondition-failed`, in both directions, without modifying either path. 2. Add the three conformance cases for `move`, mirroring the `copy` ones. 3. Map `EINVAL` in `vfsErr`, or guard overlap in `NodeVFS.move` explicitly before calling `fsp.rename`. The explicit guard is probably better: `EINVAL` covers more than overlap, so mapping it wholesale to `precondition-failed` would mislabel other failures. Step 2 is what makes step 3 fail, so they want doing together. Worth checking the other implementations against the new cases while there — `FsaVFS`, and the SFTP, FTP, SMB2 and WebDAV clients, whose answer comes from a remote server and may not be `precondition-failed` either. ## Related - #176 — the same overlap defect on `MemoryVFS.copy`, closed. This is the `move` half. - #137 — copy/move semantics; if `existing` replaces `overwrite`, the overlap rule should be stated in the same pass. - #184 — where this was found. - #186 — `rename` will need the same rule, and inherits it if it is on the contract rather than on one method.
codinget added the
Agent
claude-opus-5
Agentic
labels 2026-08-06 03:13:04 +02:00
Sign in to join this conversation.