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:
@@ -181,6 +181,9 @@ export class TransferEngine {
|
||||
try {
|
||||
const src = this.registry.get(snap.srcSessionId);
|
||||
const dst = this.registry.get(snap.dstSessionId);
|
||||
// same FILESYSTEM (never compare sessionIds — two panels on one server
|
||||
// hold two sessions, and that comparison once truncated files to zero)
|
||||
const sameFs = src.fsKey === dst.fsKey;
|
||||
|
||||
snap.state = "scanning";
|
||||
this.emit(job, true);
|
||||
@@ -189,7 +192,7 @@ export class TransferEngine {
|
||||
let files: { srcPath: string; dstPath: string; size: number; name: string }[];
|
||||
if (job.type === "dir") {
|
||||
const dstRoot = dst.join(snap.dstDir, snap.name);
|
||||
this.guardAgainstSelfCopy(snap, dstRoot);
|
||||
await this.guardAgainstSelfCopy(snap, dstRoot, sameFs, src, dst, true);
|
||||
const tree = await walkTree(src, snap.srcPath);
|
||||
for (const segments of tree.dirs) {
|
||||
if (job.cancelled) throw new Error("transfer cancelled");
|
||||
@@ -203,11 +206,21 @@ export class TransferEngine {
|
||||
}));
|
||||
} else {
|
||||
const dstPath = dst.join(snap.dstDir, snap.name);
|
||||
this.guardAgainstSelfCopy(snap, dstPath);
|
||||
await this.guardAgainstSelfCopy(snap, dstPath, sameFs, src, dst, false);
|
||||
const st = await src.stat(snap.srcPath);
|
||||
files = [{ srcPath: snap.srcPath, dstPath, size: st.size, name: snap.name }];
|
||||
}
|
||||
|
||||
// belt & suspenders: an identical path must never reach the write
|
||||
// stream (createWriteStream truncates the destination immediately)
|
||||
if (sameFs) {
|
||||
for (const file of files) {
|
||||
if (file.srcPath === file.dstPath) {
|
||||
throw new Error(`"${file.name}": source and destination are the same file`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snap.totalFiles = files.length;
|
||||
snap.totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
||||
snap.state = "running";
|
||||
@@ -281,14 +294,78 @@ export class TransferEngine {
|
||||
job.activeStreams.clear();
|
||||
}
|
||||
|
||||
/** Copying onto itself truncates the source; into a descendant never ends. */
|
||||
private guardAgainstSelfCopy(snap: TransferSnapshot, dstPath: string): void {
|
||||
if (snap.srcSessionId !== snap.dstSessionId) return;
|
||||
if (dstPath === snap.srcPath) {
|
||||
throw new Error("Source and destination are the same");
|
||||
/**
|
||||
* Copying onto itself truncates the source; into a descendant never ends.
|
||||
* Keyed on filesystem identity (fsKey), not sessionId. When the keys differ
|
||||
* but the paths collide anyway, a probe file decides whether the two
|
||||
* connections secretly reach the same machine (NAT / alias hosts).
|
||||
*/
|
||||
private async guardAgainstSelfCopy(
|
||||
snap: TransferSnapshot,
|
||||
dstPath: string,
|
||||
sameFs: boolean,
|
||||
src: FileSystemProvider,
|
||||
dst: FileSystemProvider,
|
||||
isDir: boolean,
|
||||
): Promise<void> {
|
||||
const samePath = dstPath === snap.srcPath;
|
||||
const nestedPath = dstPath.startsWith(
|
||||
snap.srcPath.endsWith("/") ? snap.srcPath : `${snap.srcPath}/`,
|
||||
);
|
||||
if (!samePath && !nestedPath) return;
|
||||
|
||||
// Paths collide. Same fsKey -> definitely the same file/folder. Different
|
||||
// fsKey -> either a coincidence (two servers both have /root/x.conf) or a
|
||||
// hidden alias of one machine; only a probe can tell (SFTP exposes no
|
||||
// device/inode identity — the technique cp/rsync use locally).
|
||||
const definitelySame =
|
||||
sameFs || (await this.probeSameFilesystem(src, dst, dst.dirname(dstPath)));
|
||||
if (!definitelySame) return;
|
||||
|
||||
if (samePath) {
|
||||
throw new Error(
|
||||
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file",
|
||||
);
|
||||
}
|
||||
if (dstPath.startsWith(snap.srcPath.endsWith("/") ? snap.srcPath : `${snap.srcPath}/`)) {
|
||||
throw new Error("Cannot copy a folder into itself");
|
||||
throw new Error("Cannot copy a folder into itself");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic same-filesystem test across two connections: write a token
|
||||
* file via dst, look for it via src. Visible + matching content = one
|
||||
* machine behind two addresses. Fails open (returns false) when the probe
|
||||
* itself cannot run (e.g. destination not writable) — the copy then
|
||||
* proceeds and permissions produce their own error.
|
||||
*/
|
||||
private async probeSameFilesystem(
|
||||
src: FileSystemProvider,
|
||||
dst: FileSystemProvider,
|
||||
dir: string,
|
||||
): Promise<boolean> {
|
||||
const token = crypto.randomUUID();
|
||||
const probePath = dst.join(dir, `.bunscp-probe-${token.slice(0, 8)}`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = dst.createWriteStream(probePath);
|
||||
stream.on("error", reject);
|
||||
stream.end(token, () => resolve());
|
||||
});
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of src.createReadStream(src.join(dir, `.bunscp-probe-${token.slice(0, 8)}`))) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
const seen = Buffer.concat(chunks).toString("utf8") === token;
|
||||
if (seen) console.log(`[engine] probe: ${src.fsKey} and ${dst.fsKey} are the same machine`);
|
||||
return seen;
|
||||
} catch {
|
||||
return false; // src cannot see the token -> different filesystems
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[engine] same-fs probe skipped: ${error instanceof Error ? error.message : error}`);
|
||||
return false;
|
||||
} finally {
|
||||
await dst.deleteFile(probePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user