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
+1
View File
@@ -24,6 +24,7 @@ function toEntry(name: string, full: string, st: Stats): FileEntry {
export class LocalProvider implements FileSystemProvider {
readonly kind = "local" as const;
readonly fsKey = "local";
join = (...parts: string[]) => path.join(...parts);
dirname = (p: string) => path.dirname(p);
+9
View File
@@ -12,6 +12,15 @@ import type { FileEntry } from "../../shared/types";
*/
export interface FileSystemProvider {
readonly kind: "local" | "sftp";
/**
* Identity of the FILESYSTEM this provider reaches, independent of the
* session: "local", or "<resolvedIp>:<port>" for SFTP. Two panels connected
* to the same server hold two SSH sessions but share one fsKey — self-copy
* guards key on this, never on sessionId (a sessionId comparison caused a
* truncate-to-zero data-loss bug). Username is deliberately excluded: the
* same path on the same host:port is the same file regardless of login.
*/
readonly fsKey: string;
join(...parts: string[]): string;
dirname(p: string): string;
basename(p: string): string;
+17 -2
View File
@@ -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));
}
});
})
+1 -1
View File
@@ -53,7 +53,7 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
sessionId = registry.add(provider);
const homeDir = await provider.homeDir();
console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`);
return { sessionId, homeDir };
return { sessionId, homeDir, fsKey: provider.fsKey };
}),
disconnect: ({ sessionId }) =>
+86 -9
View File
@@ -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(() => {});
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export type AppRPC = {
// sessions
connect: {
params: { config: ConnectionConfig };
response: Result<{ sessionId: string; homeDir: string }>;
response: Result<{ sessionId: string; homeDir: string; fsKey: string }>;
};
disconnect: { params: { sessionId: string }; response: Result };
// filesystem (uniform across "local" and sftp sessions)