"use strict"; /** * Session-backed SFTP clients (openForSession / terminal reuse) are not * ssh2-sftp-client instances. They must still expose pipelined fastPut so * uploadLocal stays on the high-throughput path * (#2449 fail-closed alignment; no serial WriteStream crawl). */ const test = require("node:test"); const assert = require("node:assert/strict"); const { EventEmitter } = require("node:events"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const sftpBridge = require("./sftpBridge.cjs"); const transferBridge = require("./transferBridge.cjs"); const tempDirBridge = require("./tempDirBridge.cjs"); const { buildConnectionReuseEndpoint, normalizeEndpoint, } = require("./sshConnectionPool.cjs"); const { TRANSFER_CHUNK_SIZE, UPLOAD_TRANSFER_CONCURRENCY, } = require("./transferLimits.cjs"); function createSessionChannel(options = {}) { const fastPutCalls = []; const remoteFiles = new Map(); const remoteMeta = new Map(); // path -> { mode, isSymlink } const chmodCalls = []; const { Readable } = require("node:stream"); const channel = { // hasSftpChannelApi requires these four methods. readdir(_targetPath, callback) { callback(null, []); }, mkdir(_targetPath, callback) { callback(null); }, unlink(targetPath, callback) { remoteFiles.delete(targetPath); remoteMeta.delete(targetPath); callback(null); }, createReadStream(targetPath) { const data = remoteFiles.get(targetPath); if (!data) { const stream = new Readable({ read() {} }); queueMicrotask(() => { const err = new Error(`ENOENT ${targetPath}`); err.code = 2; stream.destroy(err); }); return stream; } return Readable.from([Buffer.from(data)]); }, stat(targetPath, callback) { const data = remoteFiles.get(targetPath); if (!data) { const err = new Error(`ENOENT ${targetPath}`); err.code = 2; callback(err); return; } const meta = remoteMeta.get(targetPath) || {}; callback(null, { size: data.length, mode: meta.mode ?? 0o100644, mtime: meta.mtime ?? 0, uid: meta.uid ?? 0, gid: meta.gid ?? 0, isDirectory: () => false, isFile: () => true, isSymbolicLink: () => !!meta.isSymlink, }); }, lstat(targetPath, callback) { const meta = remoteMeta.get(targetPath); const data = remoteFiles.get(targetPath); if (!data && !meta) { const err = new Error(`ENOENT ${targetPath}`); err.code = 2; callback(err); return; } callback(null, { size: data ? data.length : 0, mode: meta?.isSymlink ? 0o120777 : (meta?.mode ?? 0o100644), mtime: meta?.mtime ?? 0, uid: meta?.uid ?? 0, gid: meta?.gid ?? 0, isDirectory: () => false, isFile: () => !meta?.isSymlink, isSymbolicLink: () => !!meta?.isSymlink, }); }, readlink(targetPath, callback) { const meta = remoteMeta.get(targetPath); if (!meta?.isSymlink) { const err = new Error(`ENOENT ${targetPath}`); err.code = 2; callback(err); return; } callback(null, meta.linkPath || "/missing-target"); }, fastPut(localPath, remotePath, opts, callback) { fastPutCalls.push({ localPath, remotePath, concurrency: opts?.concurrency, chunkSize: opts?.chunkSize, }); if (typeof options.onFastPut === "function") { const intercept = options.onFastPut(localPath, remotePath); if (intercept?.error) { queueMicrotask(() => callback(intercept.error)); return; } } try { const data = fs.readFileSync(localPath); remoteFiles.set(remotePath, data); if (!remoteMeta.has(remotePath)) { remoteMeta.set(remotePath, { mode: 0o100644 }); } if (typeof opts?.step === "function") { opts.step(data.length, data.length, data.length); } queueMicrotask(() => callback(null)); } catch (err) { queueMicrotask(() => callback(err)); } }, rename(from, to, callback) { if (!remoteFiles.has(from)) { const err = new Error(`ENOENT ${from}`); err.code = 2; callback(err); return; } const sourceMeta = remoteMeta.get(from); remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); remoteMeta.set(to, sourceMeta || { mode: 0o100644 }); remoteMeta.delete(from); callback(null); }, chmod(targetPath, mode, callback) { chmodCalls.push({ targetPath, mode }); const prev = remoteMeta.get(targetPath) || {}; remoteMeta.set(targetPath, { ...prev, mode }); callback(null); }, end() {}, }; return { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls }; } test("openSftpForSession rejects a same-target session reached through a different jump route", async () => { let openedChannel = false; const connection = { sftp(callback) { openedChannel = true; callback(null, createSessionChannel().channel); }, }; const base = { hostId: "same-host", hostname: "target.example", port: 22, username: "alice", authMethod: "password", password: "target-password", verifyHostKeys: true, }; const actualEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint({ ...base, jumpHosts: [{ hostId: "jump-a", hostname: "jump-a.example", username: "jump-user", password: "jump-a-password", }], })); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["split-route-session", { conn: connection, _reuseEndpoint: actualEndpoint, }]]), sftpClients: new Map(), }); await assert.rejects( sftpBridge.openSftpForSession(null, { sessionId: "split-route-session", expectedEndpoint: { ...base, jumpHosts: [{ hostId: "jump-b", hostname: "jump-b.example", username: "jump-user", password: "jump-b-password", }], }, }), (err) => err?.code === "ERR_SFTP_SOURCE_ROUTE_MISMATCH", ); assert.equal(openedChannel, false, "the mismatched transport must not be borrowed even briefly"); }); test("openSftpForSession skips a mismatched source hint and reuses another session with the full route identity", async () => { let wrongRouteOpened = false; let matchingRouteOpened = false; const wrongConnection = { sftp(callback) { wrongRouteOpened = true; callback(null, createSessionChannel().channel); }, }; const matchingConnection = { sftp(callback) { matchingRouteOpened = true; callback(null, createSessionChannel().channel); }, }; const base = { hostId: "same-host", hostname: "target.example", port: 22, username: "alice", authMethod: "password", password: "target-password", verifyHostKeys: true, knownHosts: [{ hostname: "target.example", port: 22, keyType: "ssh-ed25519", fingerprint: "target-fingerprint", }], }; const wrongEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint({ ...base, proxy: { type: "socks5", host: "proxy-a.example", port: 1080, username: "proxy-user", password: "proxy-a-password", }, jumpHosts: [{ hostId: "jump-a", hostname: "jump-a.example", username: "jump-user", password: "jump-a-password", }], })); const matchingOptions = { ...base, proxy: { type: "socks5", host: "proxy-b.example", port: 1080, username: "proxy-user", password: "proxy-b-password", }, jumpHosts: [{ hostId: "jump-b", hostname: "jump-b.example", username: "jump-user", password: "jump-b-password", }], }; const matchingEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint(matchingOptions)); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([ ["wrong-source-hint", { conn: wrongConnection, _reuseEndpoint: wrongEndpoint }], ["matching-source", { conn: matchingConnection, _reuseEndpoint: matchingEndpoint }], ]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "wrong-source-hint", expectedEndpoint: matchingOptions, fileProtocol: "sftp", }); assert.equal(wrongRouteOpened, false, "the endpoint-only source hint must never be borrowed"); assert.equal(matchingRouteOpened, true, "a session with the complete matching identity should be reused"); assert.match(opened.sftpId, /^matching-source-sftp-/); assert.equal(opened.sourceSessionId, "matching-source"); assert.equal(sftpClients.has(opened.sftpId), true); await sftpBridge.closeSftp(null, { sftpId: opened.sftpId }); }); test("strict openSftpForSession never substitutes another matching live session", async () => { let alternateOpened = false; const connection = { sftp(callback) { alternateOpened = true; callback(null, createSessionChannel().channel); }, }; const options = { hostId: "same-host", hostname: "target.example", port: 22, username: "alice", authMethod: "password", password: "target-password", verifyHostKeys: false, }; const endpoint = normalizeEndpoint(buildConnectionReuseEndpoint(options)); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["matching-alternate", { conn: connection, _reuseEndpoint: endpoint }]]), sftpClients, }); await assert.rejects( sftpBridge.openSftpForSession(null, { sessionId: "missing-requested-session", expectedEndpoint: options, requireExactSourceSession: true, fileProtocol: "sftp", }), (error) => error?.code === "ERR_SFTP_SOURCE_ROUTE_MISMATCH", ); assert.equal(alternateOpened, false); assert.equal(sftpClients.size, 0); }); test("openSftpForSession reuses the hinted session when its full route and security identity match", async () => { let openedChannel = false; const connection = { sftp(callback) { openedChannel = true; callback(null, createSessionChannel().channel); }, }; const options = { hostId: "same-host", hostname: "target.example", port: 22, username: "alice", authMethod: "password", password: "target-password", requiresMfa: true, verifyHostKeys: true, keepaliveInterval: 15, keepaliveCountMax: 4, proxy: { type: "http", host: "proxy.example", port: 8080, username: "proxy-user", password: "proxy-password", }, jumpHosts: [{ hostId: "jump", hostname: "jump.example", username: "jump-user", password: "jump-password", }], }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["matching-source", { conn: connection, _reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options)), }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "matching-source", expectedEndpoint: options, fileProtocol: "sftp", }); assert.equal(openedChannel, true); assert.match(opened.sftpId, /^matching-source-sftp-/); assert.equal(opened.sourceSessionId, "matching-source"); await sftpBridge.closeSftp(null, { sftpId: opened.sftpId }); }); test("downloadSftpToLocal aborts while initial SFTP metadata is stalled", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-stat-abort-")); t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true })); let markStatStarted; const statStarted = new Promise((resolve) => { markStatStarted = resolve; }); const client = { sftp: createSessionChannel().channel, stat() { markStatStarted(); return new Promise(() => {}); }, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map([["stalled-stat", client]]), }); const controller = new AbortController(); const download = sftpBridge.downloadSftpToLocal(null, { sftpId: "stalled-stat", remotePath: "/tmp/source.bin", localPath: path.join(tempRoot, "target.bin"), abortSignal: controller.signal, }); await statStarted; controller.abort(); await assert.rejects( () => Promise.race([ download, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]), /cancel|abort/i, ); }); test("aborted session open closes a channel that arrives after the client is discarded", async () => { let releaseOpen; let markOpenStarted; const openStarted = new Promise((resolve) => { markOpenStarted = resolve; }); const connection = { sftp(callback) { releaseOpen = callback; markOpenStarted(); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-late-open", { conn: connection }]]), sftpClients, }); const controller = new AbortController(); const opening = sftpBridge.openSftpForSession(null, { sessionId: "session-late-open", fileProtocol: "sftp", abortSignal: controller.signal, }); await openStarted; controller.abort(new Error("stop opening")); await assert.rejects(opening, /stop opening/); const created = createSessionChannel(); let endCalls = 0; created.channel.end = () => { endCalls += 1; }; releaseOpen(null, created.channel); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); assert.equal(endCalls, 1); assert.equal(sftpClients.size, 0); }); test("session-backed uploadLocalToSftp uses pipelined fastPut on the raw SFTP channel", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-session-upload-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); const payload = Buffer.alloc(48 * 1024, 17); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles } = createSessionChannel(); const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-upload", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-upload", fileProtocol: "sftp", }); assert.equal(opened.ok, true); assert.equal(opened.fileProtocol, "sftp"); // Session-backed wrapper must expose fastPut (not only raw channel). const client = sftpClients.get(opened.sftpId); assert.equal(typeof client.fastPut, "function"); assert.equal(client.__netcattySessionBacked, true); const result = await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/home/alice/payload.bin", encoding: "utf-8", }); assert.equal(result.success, true); assert.equal(fastPutCalls.length, 1); assert.equal(fastPutCalls[0].concurrency, UPLOAD_TRANSFER_CONCURRENCY); assert.equal(fastPutCalls[0].chunkSize, TRANSFER_CHUNK_SIZE); // Size-based uploads stream the live local path (no content snapshot). assert.equal(fastPutCalls[0].localPath, localPath); // Final path after staged rename assert.ok(remoteFiles.has("/home/alice/payload.bin")); assert.deepEqual(remoteFiles.get("/home/alice/payload.bin"), payload); }); test("existing destinations stage then restore mode after rename", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-meta-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "exec.bin"); const payload = Buffer.from("#!/bin/sh\necho hi\n"); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel(); remoteFiles.set("/usr/local/bin/tool", Buffer.from("old")); remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 }); const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-mode", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-mode", fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/usr/local/bin/tool", encoding: "utf-8", }); assert.equal(fastPutCalls.length, 1); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); assert.ok(remoteFiles.has("/usr/local/bin/tool")); assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), payload); // Stage+rename replaces the inode; restore prior mode bits afterwards. assert.ok( chmodCalls.some((c) => String(c.targetPath).includes(".netcatty-upload-") && (c.mode & 0o777) === 0o755), `expected mode restore via chmod, got ${JSON.stringify(chmodCalls)}`, ); assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755); }); test("mode restore failure leaves the existing destination untouched", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-mode-fail-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "tool"); await fs.promises.writeFile(localPath, Buffer.from("new-tool")); const { channel, remoteFiles, remoteMeta } = createSessionChannel(); remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool")); remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 }); channel.chmod = (_targetPath, _mode, callback) => { const err = new Error("chmod failed"); err.code = "EIO"; callback(err); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-mode-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-mode-fail", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/usr/local/bin/tool", encoding: "utf-8", }), /chmod failed/, ); assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool")); assert.equal( [...remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("failed promotion and failed restore preserve both recovery files", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-restore-fail-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "data.bin"); await fs.promises.writeFile(localPath, Buffer.from("new-data")); const { channel, remoteFiles, remoteMeta } = createSessionChannel(); const finalPath = "/tmp/data.bin"; remoteFiles.set(finalPath, Buffer.from("old-data")); remoteMeta.set(finalPath, { mode: 0o100644 }); const originalRename = channel.rename.bind(channel); let stagePromoteAttempts = 0; channel.rename = (from, to, callback) => { const fromString = String(from); const toString = String(to); if (fromString.includes(".netcatty-upload-") && toString === finalPath) { stagePromoteAttempts += 1; callback(new Error("stage promote failed")); return; } if (fromString.includes(".netcatty-backup-") && toString === finalPath) { callback(new Error("backup restore failed")); return; } originalRename(from, to, callback); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-restore-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-restore-fail", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: finalPath, encoding: "utf-8", }), /could not be restored/, ); assert.ok(stagePromoteAttempts >= 2); assert.equal(remoteFiles.has(finalPath), false); assert.ok( [...remoteFiles.entries()].some(([key, value]) => String(key).includes(".netcatty-upload-") && value.equals(Buffer.from("new-data"))), ); assert.ok( [...remoteFiles.entries()].some(([key, value]) => String(key).includes(".netcatty-backup-") && value.equals(Buffer.from("old-data"))), ); }); test("rename fallback replaces safely and restores the old target on promotion failure", async () => { const makeClient = ({ failEveryStagePromotion = false } = {}) => { const files = new Map([ ["/tmp/stage", Buffer.from("new")], ["/tmp/final", Buffer.from("old")], ]); let stagePromotionAttempts = 0; const channel = { readdir(_path, cb) { cb(null, []); }, mkdir(_path, cb) { cb(null); }, unlink(targetPath, cb) { files.delete(targetPath); cb(null); }, stat(targetPath, cb) { if (!files.has(targetPath)) { const err = new Error("ENOENT"); err.code = 2; cb(err); return; } cb(null, { size: files.get(targetPath).length, isDirectory: false }); }, }; return { files, client: { sftp: channel, async stat(targetPath) { return { size: files.get(targetPath)?.length || 0, isDirectory: false }; }, async rename(from, to) { if (from === "/tmp/stage" && to === "/tmp/final") { stagePromotionAttempts += 1; if (stagePromotionAttempts === 1 || failEveryStagePromotion) { throw new Error("overwrite unsupported"); } } if (!files.has(from)) throw new Error("ENOENT"); files.set(to, files.get(from)); files.delete(from); }, async delete(targetPath) { files.delete(targetPath); }, }, }; }; const successful = makeClient(); await sftpBridge._renameRemotePathForTests( successful.client, "/tmp/stage", "/tmp/final", "/tmp/backup", ); assert.deepEqual(successful.files.get("/tmp/final"), Buffer.from("new")); assert.equal(successful.files.has("/tmp/backup"), false); const restored = makeClient({ failEveryStagePromotion: true }); await assert.rejects( () => sftpBridge._renameRemotePathForTests( restored.client, "/tmp/stage", "/tmp/final", "/tmp/backup", ), /overwrite unsupported/, ); assert.deepEqual(restored.files.get("/tmp/final"), Buffer.from("old")); assert.deepEqual(restored.files.get("/tmp/stage"), Buffer.from("new")); assert.equal(restored.files.has("/tmp/backup"), false); const encodedStage = Buffer.from([0x81, 0x40]); const encodedFinal = Buffer.from([0x81, 0x41]); const encodedBackup = Buffer.from([0x81, 0x42]); const channel = { readdir(_path, cb) { cb(null, []); }, mkdir(_path, cb) { cb(null); }, unlink(_path, cb) { cb(null); }, stat(_path, cb) { cb(null, { size: 1, isDirectory: false }); }, }; const encodedClient = { sftp: channel, async stat() { return { size: 1, isDirectory: false }; }, async rename(from, to) { if (from === encodedFinal && to === encodedBackup) return; if (from === encodedStage && to === encodedFinal) throw new Error("promote failed"); if (from === encodedBackup && to === encodedFinal) throw new Error("restore failed"); }, async delete() {}, }; await assert.rejects( () => sftpBridge._renameRemotePathForTests( encodedClient, encodedStage, encodedFinal, encodedBackup, { stagePath: "/目录/.netcatty-upload-stage.part", backupPath: "/目录/.netcatty-backup-file.bak", finalPath: "/目录/文件.txt", }, ), (error) => { assert.match(error.message, /\/目录\/\.netcatty-upload-stage\.part/); assert.match(error.message, /\/目录\/\.netcatty-backup-file\.bak/); return true; }, ); }); test("SFTP rename fallback retries backup cleanup and reports a recoverable persistent failure", async () => { const makeClient = (persistent) => { const files = new Map([ ["/tmp/stage", Buffer.from("new")], ["/tmp/final", Buffer.from("old")], ]); let promoteAttempts = 0; let deleteAttempts = 0; return { files, get deleteAttempts() { return deleteAttempts; }, client: { sftp: { readdir(_path, cb) { cb(null, []); }, mkdir(_path, cb) { cb(null); }, unlink(_path, cb) { cb(null); }, stat(targetPath, cb) { if (!files.has(targetPath)) { const error = new Error("ENOENT"); error.code = 2; cb(error); return; } cb(null, { size: files.get(targetPath).length, isDirectory: false }); }, }, async stat(targetPath) { return { size: files.get(targetPath)?.length || 0, isDirectory: false }; }, async rename(from, to) { if (from === "/tmp/stage" && to === "/tmp/final" && promoteAttempts++ === 0) { throw new Error("overwrite unsupported"); } if (!files.has(from)) throw new Error("ENOENT"); files.set(to, files.get(from)); files.delete(from); }, async delete(targetPath) { deleteAttempts += 1; if (persistent || deleteAttempts < 3) throw new Error("backup busy"); files.delete(targetPath); }, }, }; }; const transient = makeClient(false); await sftpBridge._renameRemotePathForTests( transient.client, "/tmp/stage", "/tmp/final", "/tmp/backup", { finalPath: "/tmp/final", backupPath: "/tmp/backup" }, ); assert.equal(transient.deleteAttempts, 3); assert.deepEqual(transient.files.get("/tmp/final"), Buffer.from("new")); assert.equal(transient.files.has("/tmp/backup"), false); const persistent = makeClient(true); await assert.rejects( () => sftpBridge._renameRemotePathForTests( persistent.client, "/tmp/stage", "/tmp/final", "/tmp/backup", { finalPath: "/tmp/final", backupPath: "/tmp/backup" }, ), (error) => { assert.equal(error.recoverable, true); assert.equal(error.remoteFinalPath, "/tmp/final"); assert.equal(error.remoteBackupPath, "/tmp/backup"); return true; }, ); assert.equal(persistent.deleteAttempts, 3); assert.deepEqual(persistent.files.get("/tmp/final"), Buffer.from("new")); assert.deepEqual(persistent.files.get("/tmp/backup"), Buffer.from("old")); }); function createScpBackupCleanupHarness({ persistent }) { const finalPath = "/tmp/scp-final.bin"; const backupPath = sftpBridge._buildBackupRemotePathForTests(finalPath); const files = new Map([[finalPath, Buffer.from("old")]]); let backupDeleteAttempts = 0; let uploadCalls = 0; const missing = (candidate) => { const error = new Error(`ENOENT: ${candidate}`); error.code = "ENOENT"; return error; }; const backend = { async stat(candidate) { if (!files.has(candidate)) throw missing(candidate); return { type: "file", isDirectory: false, isSymbolicLink: false, size: files.get(candidate).length, mode: 0o100644, permissions: "rw-r--r--", modifyTime: 1, }; }, async chmod() {}, async rename(from, to) { if (!files.has(from)) throw missing(from); if (files.has(to)) throw new Error(`EEXIST: ${to}`); files.set(to, files.get(from)); files.delete(from); }, async remove(candidate) { if (candidate === backupPath) { backupDeleteAttempts += 1; if (persistent || backupDeleteAttempts < 3) throw new Error("backup cleanup unavailable"); } files.delete(candidate); }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; const upload = () => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, { expectedSize: 3, async uploadFile(_encodedPath, uploadTarget) { uploadCalls += 1; files.set(uploadTarget.logicalPath, Buffer.from("new")); }, }); return { finalPath, backupPath, files, upload, get backupDeleteAttempts() { return backupDeleteAttempts; }, get uploadCalls() { return uploadCalls; }, }; } test("SCP upload retries transient backup cleanup", async () => { const harness = createScpBackupCleanupHarness({ persistent: false }); await harness.upload(); assert.equal(harness.backupDeleteAttempts, 3); assert.deepEqual(harness.files.get(harness.finalPath), Buffer.from("new")); assert.equal(harness.files.has(harness.backupPath), false); }); test("SCP persistent cleanup failure remains recoverable and repeated upload creates no new backup", async () => { const harness = createScpBackupCleanupHarness({ persistent: true }); await assert.rejects(harness.upload, (error) => { assert.equal(error.recoverable, true); assert.equal(error.remoteFinalPath, harness.finalPath); assert.equal(error.remoteBackupPath, harness.backupPath); return true; }); assert.deepEqual(harness.files.get(harness.finalPath), Buffer.from("new")); assert.deepEqual(harness.files.get(harness.backupPath), Buffer.from("old")); assert.equal(harness.uploadCalls, 1); await assert.rejects(harness.upload, /backup cleanup unavailable/); assert.equal(harness.uploadCalls, 1, "the next attempt must reconcile the stable backup before uploading"); assert.deepEqual( [...harness.files.keys()].filter((candidate) => candidate.includes(".netcatty-backup-")), [harness.backupPath], ); }); test("SCP upload stops when the destination type cannot be inspected", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-stat-fail-")); t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true })); const localPath = path.join(tempRoot, "local.bin"); await fs.promises.writeFile(localPath, Buffer.from("payload")); let uploadCalls = 0; const backend = { async stat() { throw new Error("temporary stat failure"); }, async uploadFile() { uploadCalls += 1; }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map([["scp-stat-fail", client]]), }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: "scp-stat-fail", localPath, remotePath: "/tmp/remote.bin", encoding: "utf-8", }), /temporary stat failure/, ); assert.equal(uploadCalls, 0); }); test("SCP upload does not replace a symlink that appears before promotion", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-symlink-race-")); t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true })); const localPath = path.join(tempRoot, "local.bin"); await fs.promises.writeFile(localPath, Buffer.from("payload")); let targetStatCalls = 0; let renameCalls = 0; let removedStage = false; const backend = { async stat(remotePath) { const candidate = String(remotePath); if (candidate.includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = "ENOENT"; throw error; } if (candidate.includes(".netcatty-upload-")) { return { type: "file", isDirectory: false, size: 7 }; } targetStatCalls += 1; if (targetStatCalls === 1) return { type: "file", isDirectory: false }; return { type: "symlink", isDirectory: false, isSymbolicLink: true }; }, async uploadFile() {}, async rename() { renameCalls += 1; }, async remove(remotePath) { if (String(remotePath).includes(".netcatty-upload-")) removedStage = true; }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map([["scp-symlink-race", client]]), }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: "scp-symlink-race", localPath, remotePath: "/tmp/remote.bin", encoding: "utf-8", }), /changed to a symlink/, ); assert.equal(renameCalls, 0); assert.equal(removedStage, true); }); test("SCP upload cancelled during the final target check does not promote", async () => { const controller = new AbortController(); let targetStatCalls = 0; let renameCalls = 0; let removedStage = false; const backend = { async stat(remotePath) { const candidate = String(remotePath); if (candidate.includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = "ENOENT"; throw error; } if (candidate.includes(".netcatty-upload-")) { return { type: "file", isDirectory: false, size: 7 }; } targetStatCalls += 1; if (targetStatCalls > 1) controller.abort(); return { type: "file", isDirectory: false, size: 3 }; }, async rename() { renameCalls += 1; }, async remove(remotePath) { if (String(remotePath).includes(".netcatty-upload-")) removedStage = true; }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; await assert.rejects( () => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", "/tmp/remote.bin", { signal: controller.signal, expectedSize: 7, async uploadFile() {}, }), /abort/i, ); assert.equal(renameCalls, 0); assert.equal(removedStage, true); }); test("SCP upload aborts while staged size verification is pending", async () => { const controller = new AbortController(); let removedStage = false; const backend = { async stat(remotePath, options = {}) { if (!String(remotePath).includes(".netcatty-upload-")) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } await new Promise((resolve, reject) => { if (options.signal?.aborted) { reject(new Error("Transfer cancelled")); return; } options.signal?.addEventListener( "abort", () => reject(new Error("Transfer cancelled")), { once: true }, ); }); }, async remove(remotePath) { if (String(remotePath).includes(".netcatty-upload-")) removedStage = true; }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; const upload = sftpBridge.runRemoteUploadTransaction( client, "/tmp/local.bin", "/tmp/remote.bin", { signal: controller.signal, expectedSize: 7, async uploadFile() {}, }, ); await new Promise((resolve) => setImmediate(resolve)); controller.abort(); await assert.rejects(() => upload, /cancel|abort/i); assert.equal(removedStage, true); }); test("staged basenames stay within the remote NAME_MAX budget", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-long-name-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const longBase = `${"a".repeat(240)}.bin`; const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("x")); const { channel, fastPutCalls, remoteFiles } = createSessionChannel(); const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-long", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-long", fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: `/tmp/${longBase}`, encoding: "utf-8", }); assert.equal(fastPutCalls.length, 1); const stagedBase = path.posix.basename(fastPutCalls[0].remotePath); assert.ok(Buffer.byteLength(stagedBase, "utf8") <= 255, stagedBase); assert.ok(remoteFiles.has(`/tmp/${longBase}`)); }); test("symlink destinations are written in-place (not replaced by rename)", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-symlink-upload-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "cfg.json"); const payload = Buffer.from('{"ok":true}'); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel(); // Symlink at the destination path; real content elsewhere. remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 }); remoteFiles.set("/etc/app/config.json", Buffer.from("link-placeholder")); const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-link", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-link", fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/etc/app/config.json", encoding: "utf-8", }); assert.equal(fastPutCalls.length, 1); assert.equal(fastPutCalls[0].remotePath, "/etc/app/config.json"); assert.deepEqual(remoteFiles.get("/etc/app/config.json"), payload); }); test("truncated write-in-place uploads never delete the symlink destination", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-symlink-size-failure-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "cfg.json"); const payload = Buffer.from('{"expected":"full-content"}'); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel(); const remotePath = "/etc/app/config.json"; remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 }); remoteFiles.set(remotePath, Buffer.from("old-target")); channel.fastPut = (_localPath, uploadedPath, opts, callback) => { fastPutCalls.push({ localPath: _localPath, remotePath: uploadedPath }); const truncated = payload.subarray(0, 3); remoteFiles.set(uploadedPath, truncated); opts?.step?.(truncated.length, truncated.length, payload.length); queueMicrotask(() => callback(null)); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-link-size", { conn: { sftp: (callback) => callback(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-link-size", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /size mismatch/i, ); assert.equal(fastPutCalls.length, 1); assert.equal(remoteMeta.get(remotePath)?.isSymlink, true); assert.equal(remoteFiles.has(remotePath), true); }); test("lstat unsupported falls back to stat and preserves an existing destination", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-lstat-fallback-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "cfg.json"); const payload = Buffer.from('{"fallback":true}'); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel(); remoteFiles.set("/etc/app/config.json", Buffer.from("old-target-content")); remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 }); channel.lstat = (_targetPath, callback) => { const err = new Error("SSH_FX_OP_UNSUPPORTED"); err.code = 8; callback(err); }; const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-lstat-fallback", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-lstat-fallback", fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/etc/app/config.json", encoding: "utf-8", }); assert.equal(fastPutCalls.length, 1); assert.equal(fastPutCalls[0].remotePath, "/etc/app/config.json"); assert.deepEqual(remoteFiles.get("/etc/app/config.json"), payload); }); test("new files stage safely when readlink distinguishes them without lstat", async (t) => { for (const variant of ["missing-method", "runtime-unsupported"]) { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-new-no-lstat-${variant}-`)); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "new.bin"); const payload = Buffer.from(`new-${variant}`); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles } = createSessionChannel(); if (variant === "missing-method") { channel.lstat = undefined; } else { channel.lstat = (_targetPath, callback) => { const err = new Error("SSH_FX_OP_UNSUPPORTED"); err.code = 8; callback(err); }; } const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([[`session-${variant}`, { conn: { sftp: (cb) => cb(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: `session-${variant}`, fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: `/tmp/${variant}.bin`, encoding: "utf-8", }); assert.equal(fastPutCalls.length, 1); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); assert.deepEqual(remoteFiles.get(`/tmp/${variant}.bin`), payload); } }); test("no-lstat fallback writes through a broken symlink instead of replacing it", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-broken-link-no-lstat-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("new-target")); const created = createSessionChannel(); const remotePath = "/tmp/broken-link"; created.remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 }); created.channel.lstat = undefined; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-broken-link", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-broken-link", fileProtocol: "sftp", }); await sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }); assert.equal(created.fastPutCalls.length, 1); assert.equal(created.fastPutCalls[0].remotePath, remotePath); assert.equal(created.remoteMeta.get(remotePath)?.isSymlink, true); }); test("upload stops when neither lstat nor readlink can classify a missing target", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-no-link-inspection-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("payload")); const created = createSessionChannel(); created.channel.lstat = undefined; created.channel.readlink = undefined; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-no-link-inspection", { conn: { sftp: (callback) => callback(null, created.channel) }, }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-no-link-inspection", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/tmp/new.bin", encoding: "utf-8", }), /cannot safely distinguish/i, ); assert.equal(created.fastPutCalls.length, 0); assert.equal(created.remoteFiles.has("/tmp/new.bin"), false); }); test("permission failure during final target recheck never falls back to direct write", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-recheck-permission-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "data.bin"); await fs.promises.writeFile(localPath, Buffer.from("new-data")); const created = createSessionChannel(); created.remoteFiles.set("/tmp/data.bin", Buffer.from("old-data")); created.remoteMeta.set("/tmp/data.bin", { mode: 0o100644 }); const originalLstat = created.channel.lstat.bind(created.channel); let lstatCalls = 0; created.channel.lstat = (targetPath, callback) => { if (String(targetPath) === "/tmp/data.bin") lstatCalls += 1; if (lstatCalls >= 2 && String(targetPath) === "/tmp/data.bin") { const err = new Error("Permission denied"); err.code = "EACCES"; callback(err); return; } originalLstat(targetPath, callback); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-recheck-permission", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-recheck-permission", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/tmp/data.bin", encoding: "utf-8", }), /Permission denied/, ); assert.equal(created.fastPutCalls.length, 1); assert.match(created.fastPutCalls[0].remotePath, /\.netcatty-upload-/); assert.deepEqual(created.remoteFiles.get("/tmp/data.bin"), Buffer.from("old-data")); }); test("staged SFTP upload stops if the destination becomes a symlink", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-symlink-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "cfg.json"); await fs.promises.writeFile(localPath, Buffer.from("new-content")); let channelRef = null; const created = createSessionChannel({ onFastPut(_local, remotePath) { if (String(remotePath).includes(".netcatty-upload-")) { created.remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 }); } return null; }, }); channelRef = created.channel; created.remoteFiles.set("/etc/app/config.json", Buffer.from("old-content")); created.remoteMeta.set("/etc/app/config.json", { mode: 0o100644 }); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-symlink-race", { conn: { sftp: (cb) => cb(null, channelRef) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-symlink-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/etc/app/config.json", encoding: "utf-8", }), /changed to a symlink/, ); assert.deepEqual(created.remoteFiles.get("/etc/app/config.json"), Buffer.from("old-content")); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("staged SFTP upload preserves a regular destination replaced during upload", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-regular-target-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; const replacement = Buffer.from("new-state"); const created = createSessionChannel({ onFastPut(_local, uploadPath) { if (String(uploadPath).includes(".netcatty-upload-")) { created.remoteFiles.set(remotePath, replacement); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 2, uid: 1000, gid: 1000 }); } return null; }, }); created.remoteFiles.set(remotePath, Buffer.from("old-state")); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 }); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-regular-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-regular-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /destination changed during upload/i, ); assert.deepEqual(created.remoteFiles.get(remotePath), replacement); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("staged SFTP upload detects same-metadata destination content replacement", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-same-meta-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; // Same length/mode/owner/mtime as the original — metadata-only snapshots miss this. const replacement = Buffer.from("new-state"); const created = createSessionChannel({ onFastPut(_local, uploadPath) { if (String(uploadPath).includes(".netcatty-upload-")) { created.remoteFiles.set(remotePath, replacement); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 }); } return null; }, }); created.remoteFiles.set(remotePath, Buffer.from("old-state")); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 }); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-same-meta-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-same-meta-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /destination changed during upload/i, ); assert.deepEqual(created.remoteFiles.get(remotePath), replacement); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("staged SFTP upload detects same-size rewrite when remote inode is stable", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stable-ino-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; const replacement = Buffer.from("new-state"); const created = createSessionChannel({ onFastPut(_local, uploadPath) { if (String(uploadPath).includes(".netcatty-upload-")) { // Same inode/fileId/size/mode/mtime — only content changes. created.remoteFiles.set(remotePath, replacement); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000, ino: 4242, fileId: "stable-file-id", }); } return null; }, }); created.remoteFiles.set(remotePath, Buffer.from("old-state")); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000, ino: 4242, fileId: "stable-file-id", }); const baseStat = created.channel.stat.bind(created.channel); const baseLstat = created.channel.lstat.bind(created.channel); const withStableIds = (targetPath, callback, base) => { base(targetPath, (error, attrs) => { if (error) { callback(error); return; } const meta = created.remoteMeta.get(targetPath) || {}; callback(null, { ...attrs, ino: meta.ino, fileId: meta.fileId, }); }); }; created.channel.stat = (targetPath, callback) => withStableIds(targetPath, callback, baseStat); created.channel.lstat = (targetPath, callback) => withStableIds(targetPath, callback, baseLstat); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-stable-ino-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-stable-ino-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /destination changed during upload/i, ); assert.deepEqual(created.remoteFiles.get(remotePath), replacement); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("destination content hashing aborts when the upload signal is cancelled", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-dest-hash-abort-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; const { Readable } = require("node:stream"); let markHashStarted; const hashStarted = new Promise((resolve) => { markHashStarted = resolve; }); let streamDestroyed = false; const created = createSessionChannel(); created.remoteFiles.set(remotePath, Buffer.alloc(64 * 1024, 7)); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 }); created.channel.createReadStream = () => { const stream = new Readable({ read() { // Never push — simulate a stalled destination hash read. }, }); stream.destroy = ((originalDestroy) => function destroyPatched(err) { streamDestroyed = true; return originalDestroy.call(this, err); })(stream.destroy.bind(stream)); markHashStarted(); return stream; }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-dest-hash-abort", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-dest-hash-abort", fileProtocol: "sftp", }); const controller = new AbortController(); const upload = sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", abortSignal: controller.signal, }); await hashStarted; controller.abort(); await assert.rejects( () => Promise.race([ upload, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 1000)), ]), /abort|cancel/i, ); assert.equal(streamDestroyed, true); }); test("staged SFTP upload does not recreate a destination deleted during upload", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-deleted-target-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; const created = createSessionChannel({ onFastPut(_local, uploadPath) { if (String(uploadPath).includes(".netcatty-upload-")) { created.remoteFiles.delete(remotePath); created.remoteMeta.delete(remotePath); } return null; }, }); created.remoteFiles.set(remotePath, Buffer.from("old-state")); created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 }); const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-deleted-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-deleted-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /destination disappeared during upload/i, ); assert.equal(created.remoteFiles.has(remotePath), false); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("staged SFTP upload rechecks a replaced destination after mode setup", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mode-target-race-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("uploaded-data")); const remotePath = "/etc/app/config.json"; const replacement = Buffer.from("new-state"); const created = createSessionChannel(); created.remoteFiles.set(remotePath, Buffer.from("old-state")); created.remoteMeta.set(remotePath, { mode: 0o100755, mtime: 1, uid: 1000, gid: 1000 }); const baseChmod = created.channel.chmod.bind(created.channel); created.channel.chmod = (chmodPath, mode, callback) => { baseChmod(chmodPath, mode, (error) => { if (String(chmodPath).includes(".netcatty-upload-")) { created.remoteFiles.set(remotePath, replacement); created.remoteMeta.set(remotePath, { mode: 0o100755, mtime: 2, uid: 1000, gid: 1000 }); } callback(error); }); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-mode-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-mode-race", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /destination changed during upload/i, ); assert.deepEqual(created.remoteFiles.get(remotePath), replacement); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); }); test("no-lstat staged upload stops if a broken symlink appears before promotion", async (t) => { for (const variant of ["missing-method", "runtime-unsupported"]) { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-late-broken-link-${variant}-`)); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); await fs.promises.writeFile(localPath, Buffer.from("new-content")); const remotePath = `/tmp/${variant}.bin`; const created = createSessionChannel({ onFastPut(_local, fastPutPath) { if (String(fastPutPath).includes(".netcatty-upload-")) { created.remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 }); } return null; }, }); if (variant === "missing-method") { created.channel.lstat = undefined; } else { created.channel.lstat = (_targetPath, callback) => { const error = new Error("SSH_FX_OP_UNSUPPORTED"); error.code = 8; callback(error); }; } const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([[`session-${variant}`, { conn: { sftp: (callback) => callback(null, created.channel) }, }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: `session-${variant}`, fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath, encoding: "utf-8", }), /changed to a symlink/, ); assert.equal(created.remoteMeta.get(remotePath)?.isSymlink, true); assert.equal(created.remoteFiles.has(remotePath), false); assert.equal( [...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")), false, ); } }); test("abort during staged mode setup prevents promotion", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-mode-abort-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "tool"); await fs.promises.writeFile(localPath, Buffer.from("new-tool")); const controller = new AbortController(); const { channel, remoteFiles, remoteMeta } = createSessionChannel(); remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool")); remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 }); const originalChmod = channel.chmod.bind(channel); channel.chmod = (targetPath, mode, callback) => { controller.abort(); originalChmod(targetPath, mode, callback); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-mode-abort", { conn: { sftp: (cb) => cb(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-mode-abort", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/usr/local/bin/tool", encoding: "utf-8", abortSignal: controller.signal, }), /abort|cancel/i, ); assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool")); }); test("parent-dir permission on staged path does not risk an in-place create race", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-perm-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "data.bin"); const payload = Buffer.from("new-content"); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles } = createSessionChannel({ onFastPut(_local, remotePath) { if (String(remotePath).includes(".netcatty-upload-")) { const err = new Error("Permission denied"); err.code = 3; return { error: err }; } return null; }, }); // Destination does not exist → staging is attempted first. const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-perm", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-perm", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/ro-dir/file.bin", encoding: "utf-8", }), /Permission denied/, ); assert.equal(fastPutCalls.length, 1, "must not directly create after the staged attempt fails"); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); assert.equal(remoteFiles.has("/ro-dir/file.bin"), false); }); test("staged permission failure preserves an existing destination", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-mode-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "tool.bin"); const payload = Buffer.from("replaced-bytes"); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel({ onFastPut(_local, remotePath) { if (String(remotePath).includes(".netcatty-upload-")) { const err = new Error("Permission denied"); err.code = 3; return { error: err }; } // Simulate servers that recreate the inode on in-place OPEN|CREAT|TRUNC // with umask defaults (losing the prior executable bit). remoteMeta.set("/usr/local/bin/tool", { mode: 0o100644 }); return null; }, }); remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 }); remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool")); const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-inplace-mode", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-inplace-mode", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/usr/local/bin/tool", encoding: "utf-8", }), /Permission denied/, ); assert.equal(fastPutCalls.length, 1, "must not directly overwrite the existing file"); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); assert.deepEqual(chmodCalls, []); assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755); assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool")); }); test("staged permission failure does not reach a later mode restore failure", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-mode-fail-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "tool.bin"); await fs.promises.writeFile(localPath, Buffer.from("replaced-bytes")); const { channel, remoteFiles, remoteMeta } = createSessionChannel({ onFastPut(_local, remotePath) { if (String(remotePath).includes(".netcatty-upload-")) { const err = new Error("Permission denied"); err.code = 3; return { error: err }; } remoteMeta.set("/usr/local/bin/tool", { mode: 0o100644 }); return null; }, }); remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 }); remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool")); channel.chmod = (_targetPath, _mode, callback) => { const error = new Error("in-place chmod failed"); error.code = "EIO"; callback(error); }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-inplace-mode-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-inplace-mode-fail", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/usr/local/bin/tool", encoding: "utf-8", }), /Permission denied/, ); assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool")); assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755); }); test("SCP staged permission failure preserves an existing destination", async () => { const finalPath = "/usr/local/bin/tool"; const files = new Map([[finalPath, Buffer.from("old")]]); const modes = new Map([[finalPath, 0o100755]]); const chmodCalls = []; let uploadCalls = 0; const missing = (candidate) => { const error = new Error(`ENOENT: ${candidate}`); error.code = "ENOENT"; return error; }; const backend = { async stat(candidate) { if (!files.has(candidate)) throw missing(candidate); return { type: "file", isDirectory: false, isSymbolicLink: false, size: files.get(candidate).length, mode: modes.get(candidate) ?? 0o100644, permissions: "rwxr-xr-x", modifyTime: 1, }; }, async chmod(candidate, mode) { chmodCalls.push({ path: candidate, mode }); modes.set(candidate, 0o100000 | (mode & 0o7777)); }, async rename(from, to) { if (!files.has(from)) throw missing(from); if (files.has(to)) throw new Error(`EEXIST: ${to}`); files.set(to, files.get(from)); files.delete(from); if (modes.has(from)) { modes.set(to, modes.get(from)); modes.delete(from); } }, async remove(candidate) { files.delete(candidate); modes.delete(candidate); }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; await assert.rejects( () => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, { expectedSize: 3, async uploadFile(_path, uploadTarget) { uploadCalls += 1; if (uploadTarget.generatedStagePath) { const err = new Error("Permission denied creating stage"); err.code = "EACCES"; throw err; } files.set(uploadTarget.logicalPath, Buffer.from("new")); modes.set(uploadTarget.logicalPath, 0o100644); }, }), /Permission denied creating stage/, ); assert.equal(uploadCalls, 1); assert.deepEqual(files.get(finalPath), Buffer.from("old")); assert.deepEqual(chmodCalls, []); assert.equal(modes.get(finalPath) & 0o777, 0o755); }); test("SCP staged permission failure never commits an existing destination", async () => { const finalPath = "/usr/local/bin/tool"; const files = new Map([[finalPath, Buffer.from("old")]]); const modes = new Map([[finalPath, 0o100755]]); const chmodCalls = []; const controller = new AbortController(); const missing = (candidate) => { const error = new Error(`ENOENT: ${candidate}`); error.code = "ENOENT"; return error; }; const backend = { async stat(candidate) { if (!files.has(candidate)) throw missing(candidate); return { type: "file", isDirectory: false, isSymbolicLink: false, size: files.get(candidate).length, mode: modes.get(candidate) ?? 0o100644, permissions: "rwxr-xr-x", modifyTime: 1, }; }, async chmod(candidate, mode, options = {}) { if (options.signal?.aborted) { throw new Error("Transfer cancelled"); } chmodCalls.push({ path: candidate, mode, hadSignal: Boolean(options.signal) }); modes.set(candidate, 0o100000 | (mode & 0o7777)); }, async rename(from, to) { if (!files.has(from)) throw missing(from); if (files.has(to)) throw new Error(`EEXIST: ${to}`); files.set(to, files.get(from)); files.delete(from); if (modes.has(from)) { modes.set(to, modes.get(from)); modes.delete(from); } }, async remove(candidate) { files.delete(candidate); modes.delete(candidate); }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; let committed = false; await assert.rejects( () => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, { expectedSize: 3, signal: controller.signal, commitPromotion() { committed = true; }, async uploadFile(_path, uploadTarget) { if (uploadTarget.generatedStagePath) { const err = new Error("Permission denied creating stage"); err.code = "EACCES"; throw err; } files.set(uploadTarget.logicalPath, Buffer.from("new")); modes.set(uploadTarget.logicalPath, 0o100644); }, }), /Permission denied creating stage/, ); assert.equal(committed, false); assert.deepEqual(chmodCalls, []); assert.equal(modes.get(finalPath) & 0o777, 0o755); assert.deepEqual(files.get(finalPath), Buffer.from("old")); }); test("no-lstat new upload aborted during staged size verify leaves no final", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-late-abort-promote-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); const payload = Buffer.from("late-abort-payload"); await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls, remoteFiles } = createSessionChannel(); channel.lstat = undefined; const controller = new AbortController(); let renameCalled = false; const origStat = channel.stat.bind(channel); const origRename = channel.rename.bind(channel); channel.stat = (targetPath, callback) => { if (String(targetPath).includes(".netcatty-upload-")) { // Abort while size verification is in flight so the post-stat check must // block promotion (throwIfAborted after await client.stat). controller.abort(); queueMicrotask(() => origStat(targetPath, callback)); return; } return origStat(targetPath, callback); }; channel.rename = (from, to, callback) => { renameCalled = true; return origRename(from, to, callback); }; const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-late-abort", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-late-abort", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/tmp/late-abort.bin", encoding: "utf-8", abortSignal: controller.signal, }), /abort|cancel/i, ); assert.equal(fastPutCalls.length, 1); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); assert.equal(renameCalled, false); assert.equal(remoteFiles.has("/tmp/late-abort.bin"), false); }); test("size-mismatch on path containing 'access' does not fall back to in-place", async (t) => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-access-name-")); t.after(async () => { await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); tempDirBridge.init?.({ getPath: () => tempRoot }); const localPath = path.join(tempRoot, "payload.bin"); const payload = Buffer.from("twelve-bytes"); // 12 bytes await fs.promises.writeFile(localPath, payload); const { channel, fastPutCalls } = createSessionChannel(); // Make staged-path stat report a wrong size so size-verify throws a message // containing the path word "access" — must NOT be treated as permission. const origStat = channel.stat.bind(channel); channel.stat = (targetPath, callback) => { if (String(targetPath).includes(".netcatty-upload-")) { callback(null, { size: 1, mode: 0o100644, isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, }); return; } return origStat(targetPath, callback); }; const connection = { sftp(callback) { callback(null, channel); }, }; const sftpClients = new Map(); sftpBridge.init({ electronModule: { webContents: { fromId: () => null } }, sessions: new Map([["session-access", { conn: connection }]]), sftpClients, }); const opened = await sftpBridge.openSftpForSession(null, { sessionId: "session-access", fileProtocol: "sftp", }); await assert.rejects( () => sftpBridge.uploadLocalToSftp(null, { sftpId: opened.sftpId, localPath, remotePath: "/tmp/access-denied-name.bin", encoding: "utf-8", }), /size mismatch/i, ); // Only the staged attempt — no in-place fallback write to the final path. assert.equal(fastPutCalls.length, 1); assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/); }); test("pipelinedUploadLocalFile aborts in-flight fastPut when AbortSignal fires", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-abort-fastput-")); const localPath = path.join(tempRoot, "abort.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(64 * 1024, 3)); let ended = false; let fastPutStarted = false; const channel = { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(_p, cb) { cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, fastPut(_local, _remote, _opts, callback) { fastPutStarted = true; // Stay pending until end() cancels the transfer. this._pendingCallback = callback; }, end() { ended = true; const cb = this._pendingCallback; this._pendingCallback = null; if (typeof cb === "function") { const err = new Error("SFTP channel closed"); queueMicrotask(() => cb(err)); } }, }; const bareClient = { __netcattySessionBacked: true, sftp: null, client: { sftp(cb) { cb(null, channel); }, }, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); const controller = new AbortController(); const uploadPromise = sftpBridge.pipelinedUploadLocalFile( bareClient, localPath, "/tmp/abort-out.bin", { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, signal: controller.signal, }, ); // Allow fastPut to start, then abort. await new Promise((r) => setImmediate(r)); await new Promise((r) => setTimeout(r, 20)); assert.equal(fastPutStarted, true); controller.abort(); await assert.rejects(uploadPromise, /abort|cancel/i); assert.equal(ended, true); await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); test("shared-channel fastPut cancel force-settles when callback stalls", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-abort-bound-")); const localPath = path.join(tempRoot, "stall.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(8 * 1024, 5)); let ended = false; let unlinkedPath = null; const channel = { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(targetPath, cb) { unlinkedPath = targetPath; cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, // Never invoke the callback — simulates a stalled shared-channel fastPut. fastPut() {}, end() { ended = true; }, }; // No client.sftp() for a second channel → acquireUpload uses shared channel. const sharedOnlyClient = { __netcattySudoMode: true, sftp: channel, client: null, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); const controller = new AbortController(); const uploadPromise = sftpBridge.pipelinedUploadLocalFile( sharedOnlyClient, localPath, "/tmp/stall-out.bin", { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, signal: controller.signal, }, ); await new Promise((r) => setImmediate(r)); controller.abort(); const started = Date.now(); await assert.rejects(uploadPromise, /abort|cancel/i); const elapsed = Date.now() - started; // Must settle via the 2s force-finish path, not hang forever. assert.ok(elapsed < 5000, `cancel took too long: ${elapsed}ms`); // Shared channel must not be ended (would kill browse/sudo session). assert.equal(ended, false); // Final destinations must not be unlinked on shared-channel force-settle. assert.equal(unlinkedPath, null); await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); test("shared-channel force-settle unlinks explicitly generated stage paths", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-stage-unlink-")); const localPath = path.join(tempRoot, "stall.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 9)); let unlinkedPath = null; const channel = { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(targetPath, cb) { unlinkedPath = targetPath; cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, fastPut() {}, end() {}, }; const sharedOnlyClient = { __netcattySudoMode: true, sftp: channel, client: null, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); const controller = new AbortController(); const stagedPath = "/tmp/.netcatty-upload-deadbeef-stall.bin.part"; const uploadPromise = sftpBridge.pipelinedUploadLocalFile( sharedOnlyClient, localPath, stagedPath, { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, signal: controller.signal, generatedStagePath: true, }, ); await new Promise((r) => setImmediate(r)); controller.abort(); await assert.rejects(uploadPromise, /abort|cancel/i); assert.equal(unlinkedPath, stagedPath); await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); test("shared-channel cancel never unlinks a caller path that resembles a stage", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-stage-lookalike-")); const localPath = path.join(tempRoot, "stall.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 11)); let unlinkedPath = null; const channel = { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(targetPath, cb) { unlinkedPath = targetPath; cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, fastPut() {}, end() {}, }; const sharedOnlyClient = { __netcattySudoMode: true, sftp: channel, client: null, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); const controller = new AbortController(); const callerPath = "/tmp/.netcatty-upload-deadbeef-user-file.part"; const uploadPromise = sftpBridge.pipelinedUploadLocalFile( sharedOnlyClient, localPath, callerPath, { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, signal: controller.signal, }, ); await new Promise((r) => setImmediate(r)); controller.abort(); await assert.rejects(uploadPromise, /abort|cancel/i); assert.equal(unlinkedPath, null); await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); test("shared-channel fastPut error force-settles when callback stalls", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-error-bound-")); const localPath = path.join(tempRoot, "err.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 7)); let ended = false; const channel = new EventEmitter(); Object.assign(channel, { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(_p, cb) { cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, // Emit channel error and never invoke the fastPut callback. fastPut() { queueMicrotask(() => channel.emit("error", new Error("channel failed"))); }, end() { ended = true; }, }); const sharedOnlyClient = { __netcattySudoMode: true, sftp: channel, client: null, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); const uploadPromise = sftpBridge.pipelinedUploadLocalFile( sharedOnlyClient, localPath, "/tmp/err-out.bin", { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, }, ); const started = Date.now(); await assert.rejects(uploadPromise, /channel failed|SFTP channel/i); const elapsed = Date.now() - started; assert.ok(elapsed < 5000, `error settle took too long: ${elapsed}ms`); assert.equal(ended, false); await fs.promises.rm(tempRoot, { recursive: true, force: true }); }); test("pipelinedUploadLocalFile falls back to raw sftp.fastPut when client.fastPut is missing", async () => { const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-raw-fastput-")); const localPath = path.join(tempRoot, "raw.bin"); await fs.promises.writeFile(localPath, Buffer.from("hello-raw")); let sawRawFastPut = false; const channel = { readdir(_p, cb) { cb(null, []); }, mkdir(_p, cb) { cb(null); }, unlink(_p, cb) { cb(null); }, stat(_p, cb) { const err = new Error("ENOENT"); err.code = 2; cb(err); }, fastPut(local, remote, _opts, callback) { sawRawFastPut = local === localPath && remote === "/tmp/out.bin"; queueMicrotask(() => callback(null)); }, }; // Bare client with only raw channel.fastPut (no high-level client.fastPut). const bareClient = { sftp: channel, client: { sftp(cb) { cb(null, channel); }, }, }; sftpBridge.init({ electronModule: {}, sessions: new Map(), sftpClients: new Map(), }); await sftpBridge.pipelinedUploadLocalFile(bareClient, localPath, "/tmp/out.bin", { concurrency: UPLOAD_TRANSFER_CONCURRENCY, chunkSize: TRANSFER_CHUNK_SIZE, }); assert.equal(sawRawFastPut, true); await fs.promises.rm(tempRoot, { recursive: true, force: true }); });