Skip to content

safe-bash: mv -n cannot atomically preserve a concurrent destination through injected filesystems #701

Closed

Description

@kamilio

mv -n in published @poe-platform/safe-bash@0.1.500 can overwrite a destination created concurrently after its existence check. The public safe-fs rename operation receives only { signal }, so an injected native filesystem cannot preserve the command's no-clobber intent atomically.

The command implementation in dist/safe-bash/commands/filesystem.js checks maybeStat(target) and then calls fs.rename(source, target, { signal }). Our native default rename is atomic, but adding another destination stat in the adapter cannot close this race.

Public API reproduction (run in a package with safe-bash0.1.500):

import { Shell, agentCommands, createMemoryFileSystem } from '@poe-platform/safe-bash';
const fs = createMemoryFileSystem();
const bytes = (s) => new TextEncoder().encode(s);
await fs.writeFile('/source', bytes('source'));
const injected = new Proxy(fs, {
  get(target, key) {
    if (key === 'rename') return async (from, to, options) => {
      // Deterministically create the destination after mv's final stat.
      await fs.writeFile(to, bytes('concurrent destination'));
      return fs.rename(from, to, options);
    };
    const value = Reflect.get(target, key);
    return typeof value === 'function' ? value.bind(target) : value;
  },
});
const shell = new Shell({ fs: injected }).use(agentCommands());
try {
  const result = await shell.exec('mv -n /source /target');
  console.log(result.exitCode, new TextDecoder().decode(await fs.readFile('/target')));
} finally {
  await shell.dispose();
}

Actual: 0 source, replacing the concurrent destination. Expected: keep concurrent destination and preserve /source.

Please expose and propagate a supported atomic no-replace rename operation/capability (or equivalent public option), with native filesystem admission that refuses the mode when it cannot be guaranteed. Include deterministic concurrent-destination coverage through the public command and filesystem APIs. Default overwriting mv is separate and works with our authoritative transaction; this issue is specifically the no-clobber mode.

Activity

  1. kamilio commented on Sep 8, 2026

    @kamilio
    MemberAuthor

    Validated against the current checkout built public APIs at remote main 4c4a37f. A deterministic injected-filesystem race creates the destination at rename entry after the mv -n existence checks: the command passes only {signal}, overwrites the competing destination, deletes the source, and exits 0 even when atomicRename is declared. Controls preserve preexisting destinations, allow normal mv overwrite, and reject a disabled rename without mutation. This is a real command/contract gap, not an adapter violation: the current rename contract carries no no-replace intent, and atomic rename is not atomic no-replace. A fix must require an affirmative atomic no-replace capability/operation, preserve both entries on EEXIST, and avoid stat-plus-rename emulation or unsafe overwrite fallbacks. Cross-device behavior needs separate validation. Evidence: /tmp/kamilio-701-race-evidence.json. No fix or npm artifact qualification is claimed yet.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions