Skip to content

fix(fs): finish a cross-mount copy around an entry it cannot copy, then report them - #422

Open
mutewinter wants to merge 2 commits into
vercel-labs:mainfrom
mutewinter:fix/cross-mount-copy-reports-failed-entries
Open

mutewinter wants to merge 2 commits into
vercel-labs:mainfrom
mutewinter:fix/cross-mount-copy-reports-failed-entries

Conversation

@mutewinter

Copy link
Copy Markdown
Contributor

Problem

import { Bash, MountableFs, OverlayFs, ReadWriteFs } from "just-bash";

// docs/ holds a.txt, link.txt -> a.txt, and z.txt
const fs = new MountableFs();
fs.mount("/mnt/docs", new OverlayFs({ root: "/path/to/docs", readOnly: true }));
fs.mount("/work", new ReadWriteFs({ root: "/path/to/work" }));
const bash = new Bash({ fs, cwd: "/work" });

const { stderr, exitCode } = await bash.exec("cp -R /mnt/docs copy");
// stderr:   cp: cannot copy '/mnt/docs': EPERM: operation not permitted, symlink '/work/copy/link.txt'
// exitCode: 1
// copy/ holds a.txt and nothing else; z.txt was never reached

A recursive copy from one mount into another ends at the first entry the destination refuses, and everything after it is left uncopied. The ordinary case is a symlink: ReadWriteFs and OverlayFs refuse to create one unless allowSymlinks is set, so any tree with a link in it (a checked-out repository, a node_modules) fails this way. For an agent this reads as the folder being off limits rather than one entry being skipped, and it is easy to misattribute to the host: the errno is the one macOS raises when it denies an app a folder.

Cause

crossMountCopy recurses per child with no handling between entries:

for (const child of children) {
  const srcChild = joinPath(src, child);
  const destChild = joinPath(dest, child);
  await this.crossMountCopy(srcChild, destChild, options);
}

The first rejected promise unwinds the whole walk. The existing cross-mount tests copy between two InMemoryFs, which create symlinks, so no entry ever fails in them.

Fix

The walk continues past an entry it cannot copy and, once the tree is done, throws one error naming the entries it could not copy (capped at ten, then and N more), the way GNU cp reports each failed entry and exits 1 at the end. The cp command needs no change: it prints the error as cp: cannot copy '<src>': copied all but 2 entries: ... and exits 1.

Scope

Unchanged: a copy within one mount is delegated to that filesystem's own cp as before. A failure on the source operand itself (unreadable, missing) still throws directly. A cross-mount mv that could not copy everything leaves the source in place, as it did when the copy aborted.

Not addressed, deliberately: a symlink policy for cp (-L, --no-dereference), which is a command-level feature rather than this bug; a structured error type carrying the failures, which nothing consumes yet; per-entry lines on stderr, since the filesystem layer has no stderr and the command prints one line per source operand.

Tests

Two cases in mountable-fs.test.ts, using an InMemoryFs subclass whose symlink() throws EPERM (the policy ReadWriteFs and OverlayFs default to). The first asserts the files before, beside, and after two links (one nested) all arrive, the links do not, and the message names both in traversal order. The second asserts twelve refused links report ten and and 2 more. They do not exercise a real ReadWriteFs destination or the cp command's rendering; both are covered in the consumer suite this was found in, not here.

vitest run src/fs/ src/commands/cp/ src/commands/mv/: 33 files, 1197 passed, 1 skipped (pre-existing). tsc --noEmit in my checkout reports errors in src/commands/js-exec/run-runtime.ts only, a run module not installed here; pre-existing and untouched by this change.


Authored with Claude Opus 5

…en report them

A recursive cp from one mount into another walks the tree entry by entry, and
one entry the destination refuses ended the whole walk with only that entry
named. The ordinary case is a symlink, which ReadWriteFs and OverlayFs refuse
to create unless allowSymlinks is set, so a tree holding a single link failed
on it with everything after it left uncopied.

The walk now continues past an entry it cannot copy and throws once at the
end, naming the entries it could not copy (capped at ten), the way GNU cp
reports each failed entry and exits 1 when it is done. A copy within one
mount is that mount's own and is unchanged.
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@mutewinter is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

mutewinter added a commit to instrument-org/instrument that referenced this pull request Sep 10, 2026
cp -R of any attached folder holding a symlink stopped at the link with
EPERM: operation not permitted, symlink ..., everything after it uncopied,
and the errno was the one the attached-folders prompt told the agent to read
as macOS refusing the folder. Upstream's MountableFs ends a cross-mount walk
at the first entry the destination refuses; fixed there as
vercel-labs/just-bash#422 (the walk finishes and reports the failed entries
together, as GNU cp does) and carried as the third part of the 3.4.1 patch
until it ships, guarded by create-bash-env-cp.test.ts.

The prompt's EPERM rule narrows to reads and listings, since the destination
still reports the errno for the links it left out. A subclass overriding cp
at our layer was built first and rejected: it would shadow any upstream
change to the copy and has no removal trigger; the decision doc records it.
@auto-maintain

auto-maintain Bot commented Sep 10, 2026

Copy link
Copy Markdown

🤖 auto-maintain review

Automated, advisory triage for @mutewinter's PR. Facts below are read from the GitHub API.

Check Result
Author's merged PRs (this repo) 10
Account established ✅ (age 5931d · 190 followers · 130 public repos)
Commits signed/verified ✅ 1/1
Changeset included ✅ (.changeset/cross-mount-copy-reports-failed-entries.md)

Review panel: 🟡 medium highest severity

just-bash maintainer code review: 🟡 medium

The continuation behavior works for isolated symlink failures, but failure aggregation is unbounded and its reported entry count is incorrect for skipped subtrees.

  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:693 — The copy retains every failure message even though only ten are reported. A large rejected tree can accumulate hundreds of thousands of strings, defeating the output cap and execution resource limits; track a count while storing only the first ten messages.
  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:659 — `failures.length` counts caught operations, not uncopied entries. If creating or reading a directory fails, its entire subtree is skipped but reported as one entry, so “copied all but N entries” can materially understate missing output.

General code review: 🟡 medium

The copy behavior is correct, but failure aggregation remains unbounded in memory.

  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:693 — The ten-entry cap only limits the final message: every failure string is still retained until traversal completes. A large tree of refused entries can grow heap usage linearly and exhaust the process; track the total separately while retaining only the first ten messages.

Adversarial security: 🟡 medium

The failure aggregation introduces an unbounded-memory denial-of-service risk.

  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:693 — The ten-entry reporting cap does not bound memory: every failure message is retained. An attacker-controlled tree containing many refused symlinks can exhaust memory during a cross-mount copy. Store only the first ten messages and track the total separately.

Adversarial security (second opinion): 🟡 medium

No security defects found — the change is confined to MountableFs cross-mount copy, preserves symlink/write denial (refused entries are skipped, never forced), keeps cross-mount `mv` non-destructive because `cp` still throws, and touches no dependencies, CI, network, or process execution; the actionable issues are error-reporting accuracy and unbounded failure accumulation.

  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:680 — A directory whose destination `mkdir`/`readdir` fails is caught by the parent loop and counted as one failed "entry", so its entire subtree is silently omitted while the error reports e.g. "copied all but 1 entries". Realistic triggers: dest already has a non-directory at that name, or an ENOSPC/quota destination. The report then materially understates what is missing — the opposite of the change's stated goal, and a caller (or agent) reading "all but 1" will treat the copy as essentially complete.
  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:693 — The failure list stores only the child error's raw `message`, discarding `srcChild` which is in scope at the catch site. Consequently the "entries it could not copy" are named by whatever the destination filesystem embedded — a destination-mount-relative path (`symlink '/dir/link'`, not `/mnt/b/dir/link`, as the new test asserts), a sanitized generic message (`sanitizeFsError` yields `EIO: write '<virtualPath>'`), or no path at all (InMemoryFs `ENOSPC: in-memory filesystem byte limit exceeded (N bytes)`, giving ten identical path-less lines). The aggregated error therefore cannot be reliably used to identify which source entries are missing.
  • packages/just-bash/src/fs/mountable-fs/mountable-fs.ts:653 — `failures` accumulates one string per failed entry for the whole walk with no cap; only the rendered report is capped at ten. The comment on CROSS_MOUNT_COPY_FAILURES_REPORTED claims a tree with thousands of refused symlinks yields "one error, not one the size of the tree", but the retained array is the size of the failures. Since the pre-change behavior aborted on the first refusal, a copy into a destination that refuses most entries now retains up to `maxTraversalEntries` (default 1,000,000) messages. Truncate on push instead of on render, keeping only a count beyond the cap.

Standard Bash and host portability: 🟢 low

No actionable Bash or host-portability issues found.

Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call.

Every failure message was retained until the walk finished, so a tree the
destination refuses most of held one string per refused entry against a cap
that only shaped the message. Only the first ten are kept now; the rest are
counted.

Each named failure leads with the source entry rather than whatever path the
destination's error embedded, and a directory that failed before its children
were reached is named with its contents, since they are missing with it.
@mutewinter

Copy link
Copy Markdown
Contributor Author

Addressed the three points from the automated review in 39b0903:

  • Only the first ten failure messages are retained during the walk; the rest are counted, so memory no longer grows with the number of refused entries.
  • A directory that fails before its children are reached is named as <path> and everything in it, so the count no longer understates a skipped subtree.
  • Each named failure leads with the source entry (/mnt/a/dir/link: EPERM: ...) rather than whatever path the destination's error embedded.

New test for the skipped-directory case; the two existing ones updated for the message shape.

mutewinter added a commit to instrument-org/instrument that referenced this pull request Sep 10, 2026
Upstream's review on vercel-labs/just-bash#422 asked for three things, all
now on the branch and carried here: only the first ten failures are kept
while the walk runs (the rest are counted), each named failure leads with
the source entry rather than the destination's own path, and a directory
that failed before its children were reached is named with its contents.
Guard snapshot and the doc examples follow the new message shape.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant