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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -56,12 +56,13 @@ export class TransfersService {
|
||||
this.toast.info('Nothing to copy (symlinks are skipped)');
|
||||
return;
|
||||
}
|
||||
if (src.sessionId() === dst.sessionId() && src.cwd() === dst.cwd()) {
|
||||
const sameFs = src.fsKey() === dst.fsKey();
|
||||
if (sameFs && src.cwd() === dst.cwd()) {
|
||||
this.toast.info('Both panels show the same folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries());
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
|
||||
if (finalTargets === null) return; // user cancelled
|
||||
if (!finalTargets.length) {
|
||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
||||
@@ -84,6 +85,7 @@ export class TransfersService {
|
||||
private async resolveCollisions(
|
||||
targets: FileEntry[],
|
||||
dstEntries: FileEntry[],
|
||||
sameFs: boolean,
|
||||
): Promise<FileEntry[] | null> {
|
||||
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
|
||||
const collisions = targets.filter((t) => {
|
||||
@@ -101,6 +103,11 @@ export class TransfersService {
|
||||
finalTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
if (sameFs && dstEntry.path === target.path) {
|
||||
// replacing a file with ITSELF would only destroy it — never offer that
|
||||
this.toast.info(`Skipped "${target.name}" — it is the same file`);
|
||||
continue;
|
||||
}
|
||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user