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:
+17
-2
@@ -1,3 +1,4 @@
|
||||
import dns from "node:dns/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -24,12 +25,16 @@ function expandTilde(p: string): string {
|
||||
|
||||
export class SftpProvider implements FileSystemProvider {
|
||||
readonly kind = "sftp" as const;
|
||||
readonly fsKey: string;
|
||||
private closedByUs = false;
|
||||
|
||||
private constructor(
|
||||
private client: Client,
|
||||
private sftp: SFTPWrapper,
|
||||
) {}
|
||||
fsKey: string,
|
||||
) {
|
||||
this.fsKey = fsKey;
|
||||
}
|
||||
|
||||
/** Resolves once the SSH connection is ready and the SFTP subsystem is open. */
|
||||
static async connect(
|
||||
@@ -51,6 +56,16 @@ export class SftpProvider implements FileSystemProvider {
|
||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||
}
|
||||
|
||||
// Filesystem identity: resolve the host so a hostname and its IP compare
|
||||
// equal (self-copy guards). Falls back to the raw host string offline.
|
||||
let resolvedHost = config.host.toLowerCase();
|
||||
try {
|
||||
resolvedHost = (await dns.lookup(config.host)).address;
|
||||
} catch {
|
||||
// unresolvable (e.g. already an IP on some systems) — keep the string
|
||||
}
|
||||
const fsKey = `${resolvedHost}:${config.port}`;
|
||||
|
||||
const client = new Client();
|
||||
const provider = await new Promise<SftpProvider>((resolve, reject) => {
|
||||
client
|
||||
@@ -60,7 +75,7 @@ export class SftpProvider implements FileSystemProvider {
|
||||
client.end();
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(new SftpProvider(client, sftp));
|
||||
resolve(new SftpProvider(client, sftp, fsKey));
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user