Skip to content

fix(editors): stage external edits per-remote-file to avoid clobbering (#76)#77

Merged
macnev2013 merged 3 commits into
mainfrom
fix/76-edit-external-same-basename-collision
Jun 15, 2026
Merged

fix(editors): stage external edits per-remote-file to avoid clobbering (#76)#77
macnev2013 merged 3 commits into
mainfrom
fix/76-edit-external-same-basename-collision

Conversation

@macnev2013

@macnev2013 macnev2013 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Problem

Refs #76.

"Edit in editor" downloaded every remote file to a flat temp/anyscp-edit/<filename>. Two remote files sharing a basename — a/compose.yml and b/compose.yml — therefore mapped to the same local path. With both open in an editor, saving one re-uploaded its contents to the other's remote path, silently overwriting it. The flaw was duplicated across all three transports: sftp_edit_external, scp_edit_external, and s3_edit_external.

Fix

Namespace every edit session under a unique staging directory:

anyscp-edit/<group>/<unique>/<filename>
  • <group> — a stable DefaultHasher digest of key (session_id + "\0" + remote_path/key), so all edits of one remote file share a readable group dir. The same path on different servers also stays separate.
  • <unique> — a fresh UUID per call. This isolates every edit session, which closes two gaps a per-file hash alone couldn't:
    • Two distinct keys colliding in the 64-bit group hash still never share a file.
    • A second edit of the same remote file gets its own dir, so one save-watcher's 30-min cleanup can't delete the file out from under another live editor session.
  • The original filename is kept innermost so the editor keeps the correct name and syntax highlighting.

Teardown is centralized in a shared edit_temp_cleanup(local_path) (replacing the three duplicated cleanup blocks). It removes the file and prunes empty per-edit dirs, walking up but stopping at — and never removing — the anyscp-edit root. A still-live concurrent edit's staging dir is non-empty, so the walk halts there and leaves it intact.

Implementation

  • editors/mod.rsedit_temp_path(key, file_name) now appends the per-invocation UUID; new edit_temp_cleanup(path) helper.
  • SFTP / SCP / S3 callers — build key, create the per-edit parent dir, and call edit_temp_cleanup on teardown.

Trade-off

This intentionally drops the determinism the first cut had (same key → same path) — that property was the cause of the concurrent-edit race, and nothing relied on it (each caller captures the returned local_path directly and never reconstructs it).

Testing

  • cargo test --lib editors:: — 10 passed, including:
    • edit_temp_path_separates_same_basename — distinct dirs even for the same key, while sharing a group dir.
    • edit_temp_cleanup_prunes_dirs_but_keeps_root — pruning stops at the root.
    • edit_temp_cleanup_halts_at_nonempty_group — cleaning up edit A doesn't touch concurrent edit B's live file.
  • cargo check --lib — clean.

macnev2013 and others added 2 commits June 15, 2026 11:38
#76)

Edit-in-editor downloaded every remote file to a flat
`temp/anyscp-edit/<filename>`. Two remote files sharing a basename
(e.g. `a/compose.yml` and `b/compose.yml`) therefore mapped to the same
local path, so with both open in an editor, saving one re-uploaded its
contents to the other's remote path.

Add `editors::edit_temp_path(key, file_name)`, which namespaces each
download under `anyscp-edit/<hash>/<filename>` where `<hash>` is a stable
hash of the remote identity (session id + remote path / bucket + key).
Wire it into the SFTP, SCP, and S3 edit_external commands, and remove the
now-empty staging dir alongside the temp file on cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-file hash dir from #76 left two gaps: two distinct keys could
collide in the 64-bit group hash and share a file, and a second edit of
the *same* remote file mapped to one local path, so one save-watcher's
cleanup could delete the file out from under another live editor.

Add a per-invocation UUID layer, staging edits at
`anyscp-edit/<group>/<unique>/<file>`. The UUID isolates every edit
session, closing both gaps; the group hash is kept only so edits of one
remote file share a readable dir. Centralize teardown in
`edit_temp_cleanup`, which prunes empty per-edit dirs but stops at (and
never removes) the `anyscp-edit` root, so a concurrent edit's still-live
staging is left intact.
@macnev2013 macnev2013 force-pushed the fix/76-edit-external-same-basename-collision branch from a70ce10 to 60f5f16 Compare June 15, 2026 07:52
The ModalShell refactor (785b76f) moved HostEditModal's root into the
shared shell, which exposes only data-testid — so the data-host-modal-mode
attribute the Cmd+T e2e test reads disappeared and the test has been red
on main since. Add an optional dataAttributes pass-through to ModalShell
and set data-host-modal-mode={new|edit} from HostEditModal.
@macnev2013 macnev2013 merged commit 747ea9f into main Jun 15, 2026
7 checks passed
@macnev2013 macnev2013 deleted the fix/76-edit-external-same-basename-collision branch June 15, 2026 08:58
@macnev2013 macnev2013 added this to the v0.10.4 milestone Jun 15, 2026
@macnev2013 macnev2013 self-assigned this Jun 15, 2026
@macnev2013 macnev2013 added the bug Something isn't working label Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant