Fix data loss: copying a file onto itself via two sessions truncated it to 0

Two panels on the same server hold two SSH sessions by design, and every
self-copy guard compared sessionId — so same-server same-path copies slipped
through all of them, and createWriteStream truncated the destination before
the read began: the file (or every file in a folder) became 0 bytes, with the
overwrite dialog making it look like a legitimate replace.

Fix, defense in depth, keyed on FILESYSTEM identity instead of session:
- fsKey on every provider: 'local', or resolvedIP:port for SFTP (host is
  DNS-resolved at connect so hostname-vs-IP aliases compare equal; username
  excluded — same host:port is the same filesystem regardless of login)
- engine guard blocks equal/nested paths when fsKeys match; per-file
  belt-and-suspenders check right before streams open
- probe layer for hidden aliases (different addresses, same machine): when
  fsKeys differ but paths collide, a token file written via dst and read via
  src decides; visible => same machine => blocked. SFTP exposes no dev/inode
  (what cp/rsync use), so probing is the only deterministic test; fails open
  and cleans up after itself
- UI: same-folder toast now fires across two sessions to one server, and the
  overwrite dialog auto-skips (never offers Replace) when the colliding item
  IS the source file

Known residual, documented in README 'Same-file protection': one filesystem
mounted at different paths on two connections is undetectable client-side.

Regression suite against the real box: file-onto-itself and folder-onto-itself
blocked with contents byte-intact, folder-into-descendant blocked, legit
same-server different-dir copies still work, local same-path still blocked,
simulated alias caught by the probe with cleanup verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 21:01:20 +02:00
co-authored by Claude Fable 5
parent 06dbe14d76
commit de75fb1480
9 changed files with 153 additions and 15 deletions
+4
View File
@@ -13,6 +13,8 @@ interface PanelDeps {
export class PanelState {
readonly sessionId = signal('local');
/** Filesystem identity ("local" or resolvedIp:port) — self-copy guards use this. */
readonly fsKey = signal('local');
readonly title = signal('Local');
readonly connecting = signal(false);
readonly cwd = signal('');
@@ -339,6 +341,7 @@ export class PanelsService {
void this.rpc.request.disconnect({ sessionId: previous });
}
panel.sessionId.set(res.sessionId);
panel.fsKey.set(res.fsKey);
panel.title.set(title);
await panel.load(config.initialPath ?? res.homeDir);
return true;
@@ -357,6 +360,7 @@ export class PanelsService {
private resetToLocal(panel: PanelState): void {
panel.sessionId.set('local');
panel.fsKey.set('local');
panel.title.set('Local');
void panel.load(null);
}