const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const crypto = require("node:crypto"); const Module = require("node:module"); const { spawnSync } = require("node:child_process"); const { EventEmitter } = require("node:events"); const { PassThrough, Readable, Writable } = require("node:stream"); const transferBridge = require("./transferBridge.cjs"); const sftpBridge = require("./sftpBridge.cjs"); const tempDirBridge = require("./tempDirBridge.cjs"); const { DOWNLOAD_TRANSFER_CONCURRENCY, TRANSFER_CHUNK_SIZE, UPLOAD_TRANSFER_CONCURRENCY, } = require("./transferLimits.cjs"); function createSender() { return { sent: [], send(channel, payload) { this.sent.push({ channel, payload }); }, }; } async function waitUntil(predicate, timeoutMs = 1000) { const deadline = Date.now() + timeoutMs; while (!predicate() && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } return predicate(); } function createFastSftp(overrides) { const sftp = new EventEmitter(); sftp.readdir = (_path, callback) => callback(null, []); sftp.stat = (_path, callback) => callback(null, { size: 1024 * 1024 }); sftp.lstat = (_path, callback) => { const error = new Error("ENOENT"); error.code = 2; callback(error); }; sftp.mkdir = (_path, callback) => callback(null); sftp.unlink = (_path, callback) => callback(null); sftp.end = () => {}; Object.assign(sftp, overrides); return sftp; } /** * Pipelined READ mock for bulk SFTP downloads. Bulk transfer no longer has a * serial createReadStream path — tests must exercise open/read fanout. * * @param {Buffer | (() => Buffer)} payloadOrFactory * @param {object} [overrides] * @param {{ stall?: boolean }} [options] * stall — hold READ callbacks in `pendingReads` until releasePending() is called */ function createPipelinedDownloadSftp(payloadOrFactory, overrides = {}, options = {}) { const pendingReads = []; const getPayload = typeof payloadOrFactory === "function" ? payloadOrFactory : () => payloadOrFactory; const allowFullFileHashStream = options.allowFullFileHashStream === true; const sftp = createFastSftp({ open(_remotePath, flags, callback) { if (typeof flags === "function") { // Some call sites pass (path, callback) without flags. flags(null, Buffer.from("read-handle")); return; } callback(null, Buffer.from("read-handle")); }, read(_handle, buffer, offset, length, position, callback) { const payload = getPayload() || Buffer.alloc(0); const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); const deliver = () => { slice.copy(buffer, offset); callback(null, slice.length); }; if (options.stall) { pendingReads.push(deliver); return; } setImmediate(deliver); }, close(_handle, callback) { callback(null); }, // Hash / sample verification only (bounded start/end). Reject unbounded // opens so a reintroduced bulk createReadStream body path cannot green // success tests by streaming the full file. createReadStream(_remotePath, streamOptions = {}) { const start = streamOptions?.start; const end = streamOptions?.end; if (!allowFullFileHashStream && (!Number.isFinite(start) || !Number.isFinite(end))) { throw new Error("bulk createReadStream must not be used for SFTP download body"); } const payload = getPayload() || Buffer.alloc(0); const from = Number.isFinite(start) ? start : 0; const to = Number.isFinite(end) ? end : Math.max(0, payload.length - 1); return Readable.from([Buffer.from(payload.subarray(from, to + 1))]); }, ...overrides, }); return { sftp, pendingReads, releasePending() { for (const deliver of pendingReads.splice(0)) { try { deliver(); } catch { /* ignore */ } } }, }; } test("SFTP upload ignores cancellation after remote promotion is committed", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-commit-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.from("payload"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/payload.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old payload")]]); let releaseRename; let markRenameStarted; const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; }); let directRenameAttempted = false; const fastSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(remotePath, _flags, callback) { const key = String(remotePath); remoteFiles.set(key, Buffer.alloc(payload.length)); callback(null, Buffer.from(key)); }, write(handle, buffer, offset, length, position, callback) { const key = handle.toString(); buffer.copy(remoteFiles.get(key), position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, stat: async (remotePath) => { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, isDirectory: false }; }, chmod: async () => {}, delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); }, async rename(fromPath, toPath) { const from = String(fromPath); const to = String(toPath); if (from.includes(".netcatty-upload-") && to === targetPath && !directRenameAttempted) { directRenameAttempted = true; throw new Error("atomic replace unavailable"); } if (from.includes(".netcatty-upload-") && to === targetPath) { markRenameStarted(); await new Promise((resolve) => { releaseRename = resolve; }); } remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "sftp-commit-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); await renameStarted; await transferBridge.cancelTransfer(null, { transferId }); releaseRename(); const result = await running; assert.equal(result.error, undefined); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); }); test("replace SFTP upload restores pre-existing remote mode after the final path is published", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-replace-mode-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "tool"); const payload = Buffer.from("#!/bin/sh\necho hi\n"); await fs.promises.writeFile(localPath, payload); const targetPath = "/usr/local/bin/tool"; const remoteFiles = new Map([[targetPath, Buffer.from("old-tool")]]); const remoteMeta = new Map([[targetPath, { mode: 0o100755 }]]); const chmodCalls = []; const fastSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(remotePath, _flags, callback) { const key = String(remotePath); remoteFiles.set(key, Buffer.alloc(payload.length)); if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 }); callback(null, Buffer.from(key)); }, write(handle, buffer, offset, length, position, callback) { const key = handle.toString(); buffer.copy(remoteFiles.get(key), position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, stat: async (remotePath) => { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: false, }; }, chmod: async (remotePath, mode) => { chmodCalls.push({ path: String(remotePath), mode }); const prev = remoteMeta.get(String(remotePath)) || {}; remoteMeta.set(String(remotePath), { ...prev, mode: (mode & 0o7777) | 0o100000 }); }, delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); remoteMeta.delete(String(remotePath)); }, async rename(fromPath, toPath) { const from = String(fromPath); const to = String(toPath); remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); // Simulate servers that recreate the destination inode on rename-replace // with umask defaults, dropping the mode restored on the `.part` stage. remoteMeta.set(to, { mode: 0o100644 }); remoteMeta.delete(from); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "sftp-replace-preserve-mode", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.equal(result.error, undefined, result.error); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.ok( chmodCalls.some((call) => call.path === targetPath && (call.mode & 0o7777) === 0o755), `expected chmod of ${targetPath} to 0755 after replace, got ${JSON.stringify(chmodCalls)}`, ); assert.equal(remoteMeta.get(targetPath)?.mode & 0o777, 0o755); }); test("new SFTP upload does not chmod a path that did not already exist", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-new-mode-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "fresh.bin"); const payload = Buffer.from("new-bytes"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/fresh.bin"; const remoteFiles = new Map(); const remoteMeta = new Map(); const chmodCalls = []; const fastSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(remotePath, _flags, callback) { const key = String(remotePath); remoteFiles.set(key, Buffer.alloc(payload.length)); if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 }); callback(null, Buffer.from(key)); }, write(handle, buffer, offset, length, position, callback) { const key = handle.toString(); buffer.copy(remoteFiles.get(key), position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, stat: async (remotePath) => { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: false, }; }, chmod: async (remotePath, mode) => { chmodCalls.push({ path: String(remotePath), mode }); }, delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); remoteMeta.delete(String(remotePath)); }, async rename(fromPath, toPath) { const from = String(fromPath); const to = String(toPath); remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); remoteMeta.set(to, remoteMeta.get(from) || { mode: 0o100644 }); remoteMeta.delete(from); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "sftp-new-keeps-default-mode", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.equal(result.error, undefined, result.error); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.deepEqual(chmodCalls, []); }); test("replace SFTP upload still succeeds when restoring remote mode fails", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-replace-chmod-fail-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "tool"); const payload = Buffer.from("replaced-bytes"); await fs.promises.writeFile(localPath, payload); const targetPath = "/usr/local/bin/tool"; const remoteFiles = new Map([[targetPath, Buffer.from("old-tool")]]); const remoteMeta = new Map([[targetPath, { mode: 0o100755 }]]); const chmodCalls = []; const warnings = []; const originalWarn = console.warn; console.warn = (...args) => { warnings.push(args.map(String).join(" ")); }; t.after(() => { console.warn = originalWarn; }); const fastSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(remotePath, _flags, callback) { const key = String(remotePath); remoteFiles.set(key, Buffer.alloc(payload.length)); if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 }); callback(null, Buffer.from(key)); }, write(handle, buffer, offset, length, position, callback) { const key = handle.toString(); buffer.copy(remoteFiles.get(key), position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, stat: async (remotePath) => { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, mode: remoteMeta.get(key)?.mode ?? 0o100644, isDirectory: false, }; }, chmod: async (remotePath, mode) => { chmodCalls.push({ path: String(remotePath), mode }); if (String(remotePath) === targetPath) { throw new Error("chmod failed"); } }, delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); remoteMeta.delete(String(remotePath)); }, async rename(fromPath, toPath) { const from = String(fromPath); const to = String(toPath); remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); remoteMeta.set(to, remoteMeta.get(from) || { mode: 0o100644 }); remoteMeta.delete(from); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "sftp-replace-chmod-fail", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.equal(result.error, undefined, result.error); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.ok( chmodCalls.some((call) => call.path === targetPath && (call.mode & 0o7777) === 0o755), `expected chmod of ${targetPath} to 0755, got ${JSON.stringify(chmodCalls)}`, ); assert.ok( warnings.some((message) => /Failed to restore permissions/.test(message)), `expected chmod warning, got ${JSON.stringify(warnings)}`, ); }); test("SCP upload ignores cancellation after remote promotion is committed", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-commit-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.from("payload"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/payload.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old payload")]]); let releaseRename; let markRenameStarted; const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; }); const backend = { async mkdir() {}, async stat(remotePath) { if (!remoteFiles.has(remotePath)) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length }; }, async uploadFile(localSourcePath, remotePath, options = {}) { remoteFiles.set(remotePath, await fs.promises.readFile(localSourcePath)); options.onProgress?.(payload.length, payload.length); }, async rename(fromPath, toPath) { if (fromPath === targetPath) { markRenameStarted(); await new Promise((resolve) => { releaseRename = resolve; }); } remoteFiles.set(toPath, remoteFiles.get(fromPath)); remoteFiles.delete(fromPath); }, async remove(remotePath) { remoteFiles.delete(remotePath); }, async chmod() {}, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "scp-commit-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); await renameStarted; await transferBridge.cancelTransfer(null, { transferId }); releaseRename(); const result = await running; assert.equal(result.error, undefined); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); }); test("SCP upload streams the live local path without a pre-hash digest", async (t) => { // Industry consensus (FileZilla / WinSCP / OpenSSH): stream bytes immediately. // Content digests are not a precondition for SCP body transfer. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-direct-stream-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.alloc(16 * 1024, 57); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/payload.bin"; const remoteFiles = new Map(); let uploadSourcePath = null; const backend = { async stat(remotePath) { if (!remoteFiles.has(remotePath)) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length }; }, async uploadFile(sourcePath, remotePath, options) { uploadSourcePath = sourcePath; // May still receive a plain file stream helper; must not require a digest. if (typeof options.openReadStream === "function") { const opened = options.openReadStream(); const chunks = []; for await (const chunk of opened.stream) chunks.push(chunk); remoteFiles.set(remotePath, Buffer.concat(chunks)); } else { remoteFiles.set(remotePath, await fs.promises.readFile(sourcePath)); } options.onProgress?.(payload.length, payload.length); }, async remove() {}, async rename() {}, async chmod() {}, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "scp-direct-stream", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.equal(result.error, undefined); assert.equal(uploadSourcePath, localPath); // Staging may write to a .part path then rename; accept any staged body. const uploaded = [...remoteFiles.values()].find((buf) => Buffer.isBuffer(buf) && buf.length === payload.length); assert.ok(uploaded, "expected SCP body to be written to a remote path"); assert.deepEqual(uploaded, payload); }); test("SCP staged streaming preserves executable mode without a local snapshot", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-executable-mode-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "tool.sh"); const payload = Buffer.from("#!/bin/sh\necho ok\n"); await fs.promises.writeFile(localPath, payload); await fs.promises.chmod(localPath, 0o755); const targetPath = "/usr/local/bin/tool.sh"; const remoteFiles = new Map(); let uploadedMode = null; let uploadedSourcePath = null; const backend = { async stat(remotePath) { if (!remoteFiles.has(remotePath)) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length }; }, async uploadFile(sourcePath, remotePath, options) { uploadedSourcePath = sourcePath; uploadedMode = (await fs.promises.stat(sourcePath)).mode & 0o777; assert.equal(options.fileSize, payload.length); const opened = options.openReadStream(); const chunks = []; for await (const chunk of opened.stream) chunks.push(chunk); remoteFiles.set(remotePath, Buffer.concat(chunks)); }, async rename(fromPath, toPath) { remoteFiles.set(toPath, remoteFiles.get(fromPath)); remoteFiles.delete(fromPath); }, async remove(remotePath) { remoteFiles.delete(remotePath); }, async chmod() {}, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "scp-executable-mode", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.equal(result.error, undefined); assert.equal(uploadedSourcePath, localPath); assert.equal(uploadedMode, 0o755); assert.deepEqual(remoteFiles.get(targetPath), payload); const digestId = crypto.createHash("sha256") .update("scp-executable-mode") .digest("hex") .slice(0, 16); const snapshotPath = tempDirBridge.getTransferTempFilePath( `upload-source-${digestId}`, "snapshot.bin", ); assert.equal(fs.existsSync(snapshotPath), false); }); test("SCP staged upload rechecks a deleted destination after mode setup", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-mode-target-race-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.from("uploaded-data"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/payload.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old-state")]]); const remoteMeta = new Map([[targetPath, { mode: 0o100755, modifyTime: 1 }]]); let renameCalls = 0; const backend = { async stat(remotePath) { if (!remoteFiles.has(remotePath)) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } const meta = remoteMeta.get(remotePath) || { mode: 0o100644, modifyTime: 0 }; return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length, mode: meta.mode, permissions: "rwxr-xr-x", modifyTime: meta.modifyTime, }; }, async uploadFile(_sourcePath, remotePath, options) { const opened = options.openReadStream(); const chunks = []; for await (const chunk of opened.stream) chunks.push(chunk); remoteFiles.set(remotePath, Buffer.concat(chunks)); remoteMeta.set(remotePath, { mode: 0o100644, modifyTime: 0 }); }, async chmod(remotePath) { if (remotePath.includes(".netcatty-upload-")) { remoteFiles.delete(targetPath); remoteMeta.delete(targetPath); } }, async rename(fromPath, toPath) { renameCalls += 1; remoteFiles.set(toPath, remoteFiles.get(fromPath)); remoteFiles.delete(fromPath); }, async remove(remotePath) { remoteFiles.delete(remotePath); remoteMeta.delete(remotePath); }, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "scp-mode-target-race", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.match(result.error || "", /destination disappeared during upload/i); assert.equal(renameCalls, 0); assert.equal(remoteFiles.has(targetPath), false); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false); }); test("SCP staged upload does not promote when AbortSignal cancels mid-stream", async (t) => { // Size-based path has no final digest re-scan. Cancel during the body stream // must still prevent rename to the final target. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-midstream-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.alloc(16 * 1024, 91); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/payload.bin"; const remoteFiles = new Map(); let renameCalls = 0; let releaseBodyRead; let markBodyStarted; const bodyStarted = new Promise((resolve) => { markBodyStarted = resolve; }); const backend = { async stat(remotePath) { if (!remoteFiles.has(remotePath)) { const error = new Error("No such file"); error.code = "ENOENT"; throw error; } return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length }; }, async uploadFile(_sourcePath, remotePath, options) { const opened = options.openReadStream(); const chunks = []; let first = true; for await (const chunk of opened.stream) { if (first) { first = false; markBodyStarted(); await new Promise((resolve) => { releaseBodyRead = resolve; }); } chunks.push(chunk); } remoteFiles.set(remotePath, Buffer.concat(chunks)); }, async rename(fromPath, toPath) { renameCalls += 1; remoteFiles.set(toPath, remoteFiles.get(fromPath)); remoteFiles.delete(fromPath); }, async remove(remotePath) { remoteFiles.delete(remotePath); }, async chmod() {}, }; const client = { __netcattyFileProtocol: "scp", __netcattyScpBackend: backend, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const controller = new AbortController(); const transferId = "scp-midstream-cancel"; const running = transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, abortSignal: controller.signal, }); await bodyStarted; controller.abort(); releaseBodyRead(); const result = await running; assert.match(result.error || "", /cancel/i); assert.equal(renameCalls, 0); assert.equal(remoteFiles.has(targetPath), false); }); test("in-place upload ignores cancellation during final size verification", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-in-place-commit-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.from("payload"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/link.bin"; let remote = Buffer.from("old payload"); let statCalls = 0; let releaseFinalStat; let markFinalStatStarted; const finalStatStarted = new Promise((resolve) => { markFinalStatStarted = resolve; }); const fastSftp = createFastSftp({ lstat(remotePath, callback) { if (String(remotePath).includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remote.length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }, open(_remotePath, _flags, callback) { remote = Buffer.alloc(payload.length); callback(null, Buffer.from("remote-handle")); }, write(_handle, buffer, offset, length, position, callback) { buffer.copy(remote, position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, async stat(remotePath) { if (String(remotePath).includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = "ENOENT"; throw error; } statCalls += 1; // Hang on the post-upload size check (after OPEN replaced the payload), // not on an earlier existence/mode probe. if (remote.length === payload.length && !releaseFinalStat) { markFinalStatStarted(); await new Promise((resolve) => { releaseFinalStat = resolve; }); } return { size: remote.length }; }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "in-place-commit-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); await finalStatStarted; await transferBridge.cancelTransfer(null, { transferId }); releaseFinalStat(); const result = await running; assert.equal(result.error, undefined); assert.deepEqual(remote, payload); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); assert.equal(sender.sent.some((entry) => ( entry.channel === "netcatty:transfer:progress" && entry.payload.transferred === payload.length && entry.payload.totalBytes === payload.length )), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); }); test("in-place concurrent upload ignores cancellation while the remote handle closes", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-in-place-close-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.from("payload"); await fs.promises.writeFile(localPath, payload); let remote = Buffer.from("old payload"); let releaseClose; let markCloseStarted; const closeStarted = new Promise((resolve) => { markCloseStarted = resolve; }); const fastSftp = createFastSftp({ lstat(remotePath, callback) { if (String(remotePath).includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remote.length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }, open(_remotePath, _flags, callback) { remote = Buffer.alloc(payload.length); callback(null, Buffer.from("remote-handle")); }, write(_handle, buffer, offset, length, position, callback) { buffer.copy(remote, position, offset, offset + length); setImmediate(() => callback(null)); }, close(_handle, callback) { markCloseStarted(); releaseClose = () => callback(null); }, }); const client = { __netcattySudoMode: true, sftp: fastSftp, stat: async (remotePath) => { if (String(remotePath).includes(".netcatty-backup-")) { const error = new Error("ENOENT"); error.code = "ENOENT"; throw error; } return { size: remote.length }; }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "in-place-close-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/link.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); await closeStarted; await transferBridge.cancelTransfer(null, { transferId }); releaseClose(); const result = await running; assert.equal(result.error, undefined); assert.deepEqual(remote, payload); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); }); test("SFTP download cancellation settles while initial metadata is stalled", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-download-stat-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); let markStatStarted; const statStarted = new Promise((resolve) => { markStatStarted = resolve; }); const client = { sftp: createFastSftp({}), stat() { markStatStarted(); return new Promise(() => {}); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-stat-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: "/tmp/source.bin", targetPath: path.join(tempDir, "target.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", resumable: true, }); await statStarted; await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]); assert.match(result.error || "", /cancel/i); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("SFTP download cancellation settles while source snapshot metadata is stalled", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-download-snapshot-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); let statCalls = 0; let markSnapshotStarted; const snapshotStarted = new Promise((resolve) => { markSnapshotStarted = resolve; }); const client = { sftp: createFastSftp({}), stat() { statCalls += 1; if (statCalls === 1) return Promise.resolve({ size: 1024 }); markSnapshotStarted(); return new Promise(() => {}); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-snapshot-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: "/tmp/source.bin", targetPath: path.join(tempDir, "target.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", resumable: true, }); await snapshotStarted; await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]); assert.equal(statCalls, 2); assert.match(result.error || "", /cancel/i); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("SFTP upload cancellation settles while remote directory setup is stalled", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-dir-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "source.bin"); await fs.promises.writeFile(localPath, Buffer.from("payload")); let releaseStat; let statCalls = 0; let markStatStarted; const statStarted = new Promise((resolve) => { markStatStarted = resolve; }); const createdDirectories = []; const stalledSftp = createFastSftp({ stat(_remotePath, callback) { statCalls += 1; if (statCalls > 1) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } releaseStat = () => { const error = new Error("ENOENT"); error.code = 2; callback(error); }; markStatStarted(); }, mkdir(remotePath, callback) { createdDirectories.push(String(remotePath)); callback(null); }, }); const client = { sftp: stalledSftp, }; const clients = new Map([["target", client]]); sftpBridge.init({ sftpClients: clients, sessions: new Map(), electronModule: {} }); t.after(() => { sftpBridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} }); }); transferBridge.init({ sftpClients: clients }); const sender = createSender(); const transferId = "upload-directory-cancel"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath: "/stalled/directory/target.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", resumable: false, }); await statStarted; await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]); assert.match(result.error || "", /cancel/i); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); assert.deepEqual(createdDirectories, []); releaseStat(); await new Promise((resolve) => setTimeout(resolve, 20)); assert.deepEqual(createdDirectories, []); }); test("SFTP upload cancellation waits for an in-flight remote mkdir to settle", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-mkdir-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "source.bin"); await fs.promises.writeFile(localPath, Buffer.from("payload")); let releaseMkdir; let markMkdirStarted; const mkdirStarted = new Promise((resolve) => { markMkdirStarted = resolve; }); const createdDirectories = []; const stalledSftp = createFastSftp({ stat(_remotePath, callback) { const error = new Error("ENOENT"); error.code = 2; callback(error); }, mkdir(remotePath, callback) { const directory = String(remotePath); markMkdirStarted(); releaseMkdir = () => { createdDirectories.push(directory); callback(null); }; }, }); const client = { sftp: stalledSftp }; const clients = new Map([["target", client]]); sftpBridge.init({ sftpClients: clients, sessions: new Map(), electronModule: {} }); t.after(() => { sftpBridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} }); }); transferBridge.init({ sftpClients: clients }); const sender = createSender(); const transferId = "upload-mkdir-cancel"; let settled = false; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath: "/stalled/directory/target.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", resumable: false, }).finally(() => { settled = true; }); await mkdirStarted; await transferBridge.cancelTransfer(null, { transferId }); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(settled, false); assert.deepEqual(createdDirectories, []); releaseMkdir(); const result = await running; assert.match(result.error || "", /cancel/i); assert.deepEqual(createdDirectories, ["/stalled"]); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("cancelling one SFTP channel waiter does not cancel the shared reopen", async () => { let openCalls = 0; let releaseChannel; const reopenedChannel = createFastSftp({}); const client = { sftp: null, client: { sftp(callback) { openCalls += 1; releaseChannel = () => callback(null, reopenedChannel); }, }, }; const firstController = new AbortController(); const secondController = new AbortController(); const firstWait = sftpBridge.requireSftpChannel(client, { signal: firstController.signal }); const secondWait = sftpBridge.requireSftpChannel(client, { signal: secondController.signal }); firstController.abort(new Error("first waiter cancelled")); await assert.rejects(() => firstWait, /first waiter cancelled/i); assert.equal(openCalls, 1); releaseChannel(); assert.equal(await secondWait, reopenedChannel); assert.equal(client.sftp, reopenedChannel); assert.equal(openCalls, 1); }); test("shared SFTP channel reopen preserves the requested timeout", async () => { const client = { sftp: null, client: { sftp() {}, }, }; const startedAt = Date.now(); await assert.rejects( () => sftpBridge.requireSftpChannel(client, { timeoutMs: 10 }), /session lost/i, ); assert.ok(Date.now() - startedAt < 500); assert.equal(client._reopeningPromise, null); }); test("server-to-server resume cancellation settles while source identity verification is stalled", async (t) => { const transferId = `server-prefix-cancel-${crypto.randomUUID()}`; const sourcePath = "/source/payload.bin"; const tempPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath)); await fs.promises.writeFile(tempPath, Buffer.from("abcd")); t.after(async () => fs.promises.rm(tempPath, { force: true })); let markPrefixStarted; const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; }); const sourceClient = { sftp: createFastSftp({ createReadStream() { markPrefixStarted(); return new PassThrough(); }, }), }; const targetClient = { sftp: createFastSftp({}) }; transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]), }); const sender = createSender(); const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath, targetPath: "/target/payload.bin", sourceType: "sftp", targetType: "sftp", sourceSftpId: "source", targetSftpId: "target", totalBytes: 8, resumable: true, sameHost: false, resumeStage: "download", checkpointBytes: 4, downloadCheckpointBytes: 4, sourceFingerprint: `sha256:${"0".repeat(64)}`, }); await prefixStarted; await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]); assert.match(result.error || "", /cancel/i); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("resumable SFTP uploads use the configured per-file request concurrency", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Need more than one fanout wave so the concurrency cap is observable. const fileSize = UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2; const localPath = path.join(tempDir, "large.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(fileSize)); let activeWrites = 0; let observedConcurrency = 0; let observedChunkSize = 0; const pendingWrites = []; let holdWrites = true; const fastSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "w"); callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, _position, callback) { activeWrites += 1; observedConcurrency = Math.max(observedConcurrency, activeWrites); observedChunkSize = Math.max(observedChunkSize, length); if (holdWrites) { pendingWrites.push(() => { activeWrites -= 1; callback(null); }); return; } setImmediate(() => { activeWrites -= 1; callback(null); }); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: fileSize }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-large", sourcePath: localPath, targetPath: "/tmp/large.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY); assert.equal(observedConcurrency, UPLOAD_TRANSFER_CONCURRENCY); assert.equal(observedChunkSize, TRANSFER_CHUNK_SIZE); holdWrites = false; for (const complete of pendingWrites.splice(0)) complete(); const result = await running; assert.equal(result.error, undefined); }); test("fast resumable uploads pause only after in-flight ranges are durable", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fast-upload-pause-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // First in-flight wave must be smaller than the file so pause can land mid-transfer. const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 13); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const pendingWrites = []; let holdWrites = true; let durableBytes = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { const complete = () => { durableBytes = Math.max(durableBytes, position + length); callback(null); }; if (holdWrites) pendingWrites.push(complete); else setImmediate(complete); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: durableBytes }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-fast-paused", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY); const pausing = transferBridge.pauseTransfer(null, { transferId: "upload-fast-paused" }); holdWrites = false; for (const complete of pendingWrites.splice(0)) complete(); const paused = await pausing; assert.equal(paused.success, true); assert.equal(paused.checkpointBytes, UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE); { const resumed = await transferBridge.resumeTransfer(null, { transferId: "upload-fast-paused" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } assert.equal((await running).error, undefined); assert.equal(durableBytes, payload.length); }); test("pause soft-drains concurrent ranges but resume waits before truncating", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pause-soft-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Several MB so concurrent fanout stays saturated while we hold writes. const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let holdWrites = true; const pendingWrites = []; let durableBytes = 0; const truncateCalls = []; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { const complete = () => { durableBytes = Math.max(durableBytes, position + length); callback(null); }; if (holdWrites) pendingWrites.push({ position, complete }); else setImmediate(complete); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: durableBytes }); }, truncate(_remotePath, size) { truncateCalls.push(size); durableBytes = size; return Promise.resolve(); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-soft-pause", sourcePath: localPath, targetPath: "/tmp/upload-soft.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes"); const started = Date.now(); // Hold writes so active ranges never drain — soft-drain must still resolve. const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-soft-pause" }); const elapsed = Date.now() - started; assert.equal(paused.success, true); // Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain. assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`); const outOfOrderWrite = pendingWrites.pop(); assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint"); outOfOrderWrite.complete(); await new Promise((resolve) => setImmediate(resolve)); let resumeSettled = false; const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-soft-pause" }) .then((result) => { resumeSettled = true; return result; }); await new Promise((resolve) => setTimeout(resolve, 500)); const truncatedBeforeDrain = truncateCalls.length > 0; const resumedBeforeDrain = resumeSettled; holdWrites = false; for (const { complete } of pendingWrites.splice(0)) complete(); const resumeResult = await resuming; assert.equal(resumeResult.success, true); assert.ok(Number.isFinite(resumeResult.lifecycleEpoch)); assert.equal((await running).error, undefined); assert.equal(truncatedBeforeDrain, false, "must not truncate while range writes are active"); assert.equal(resumedBeforeDrain, false, "resume must wait for active range writes to settle"); assert.equal(durableBytes, payload.length); }); for (const settleWrites of [true, false]) { test(`a newer pause supersedes resume while concurrent writes are draining: settle=${settleWrites}`, async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-repause-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Several MB so concurrent fanout stays saturated while we hold writes. const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let holdWrites = true; const pendingWrites = []; let durableBytes = 0; const truncateCalls = []; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { const complete = () => { durableBytes = Math.max(durableBytes, position + length); callback(null); }; if (holdWrites) pendingWrites.push({ position, complete }); else setImmediate(complete); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: durableBytes }); }, truncate(_remotePath, size) { truncateCalls.push(size); durableBytes = size; return Promise.resolve(); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-resume-repause", sourcePath: localPath, targetPath: "/tmp/upload-soft.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes"); const started = Date.now(); // Hold writes so active ranges never drain — soft-drain must still resolve. const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" }); const elapsed = Date.now() - started; assert.equal(paused.success, true); // Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain. assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`); const outOfOrderWrite = pendingWrites.pop(); assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint"); outOfOrderWrite.complete(); await new Promise((resolve) => setImmediate(resolve)); let resumeSettled = false; const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-resume-repause" }) .then((result) => { resumeSettled = true; return result; }); await new Promise((resolve) => setTimeout(resolve, 500)); const truncatedBeforeDrain = truncateCalls.length > 0; const resumedBeforeDrain = resumeSettled; const newerPause = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" }); assert.equal(newerPause.success, true); if (settleWrites) { holdWrites = false; for (const { complete } of pendingWrites.splice(0)) complete(); } const resumeResult = await resuming; holdWrites = false; for (const { complete } of pendingWrites.splice(0)) complete(); await transferBridge.cancelTransfer(null, { transferId: "upload-resume-repause" }); await running; assert.equal(resumeResult.success, false, "old resume must not restart writes after a newer pause"); if (settleWrites) assert.match(resumeResult.reason, /superseded.*pause/i); assert.equal(resumeResult.superseded, true, "direct callers need a structured superseded result too"); assert.equal(resumeResult.supersededBy, "pause"); assert.equal(truncatedBeforeDrain, false); assert.equal(resumedBeforeDrain, false); }); } test("cancelling resume during soft-drain does not truncate the staged file", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 66); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let holdWrites = true; const pendingWrites = []; const truncateCalls = []; let durableBytes = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { const complete = () => { durableBytes = Math.max(durableBytes, position + length); callback(null); }; if (holdWrites) pendingWrites.push(complete); else setImmediate(complete); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: durableBytes }); }, truncate(_remotePath, size) { truncateCalls.push(size); durableBytes = size; return Promise.resolve(); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const transferId = "upload-resume-cancel"; const running = transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-resume-cancel.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY); assert.equal((await transferBridge.pauseTransfer(null, { transferId })).success, true); const resuming = transferBridge.resumeTransfer(null, { transferId }); await transferBridge.cancelTransfer(null, { transferId }); holdWrites = false; for (const complete of pendingWrites.splice(0)) complete(); const transferResult = await running; const resumeResult = await resuming; assert.match(transferResult.error || "", /cancelled/i); assert.deepEqual(resumeResult, { success: false, reason: "Transfer is no longer active", }); assert.deepEqual(truncateCalls, []); }); test("resuming while a fast pause is pending settles the pause request", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pause-resume-race-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(32 * 1024, 67); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let finishWrite = null; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { finishWrite = () => callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: payload.length }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId: "pause-resume-race", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); const deadline = Date.now() + 1000; while (!finishWrite && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(typeof finishWrite, "function"); const pausing = transferBridge.pauseTransfer(null, { transferId: "pause-resume-race" }); { const resumed = await transferBridge.resumeTransfer(null, { transferId: "pause-resume-race" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } assert.deepEqual(await pausing, { success: false, reason: "Pause was superseded by resume", superseded: true, supersededBy: "resume", }); finishWrite(); assert.equal((await running).error, undefined); }); test("pause acknowledges quickly then publishes a full source identity", async (t) => { // Pause acknowledgement must not wait for full-file SHA-256. The background // identity is nevertheless a complete digest so a later resume cannot mix // bytes from different source versions. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-late-pause-race-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 71); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const pendingWrites = []; let holdWrites = true; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { if (holdWrites) pendingWrites.push(callback); else setImmediate(() => callback(null)); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: payload.length }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); let running; { const sender = createSender(); running = transferBridge.startTransfer( { sender }, { transferId: "late-pause-race", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); t.after(async () => { await transferBridge.cancelTransfer(null, { transferId: "late-pause-race" }); await running?.catch(() => {}); transferBridge.clearPendingCancel("late-pause-race"); }); const writeDeadline = Date.now() + 1000; while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < writeDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY); const pauseStarted = Date.now(); const pausing = transferBridge.pauseTransfer(null, { transferId: "late-pause-race" }); holdWrites = false; for (const callback of pendingWrites.splice(0)) callback(null); const paused = await pausing; const pauseElapsed = Date.now() - pauseStarted; assert.equal(paused.success, true); assert.ok(pauseElapsed < 1500, `pause blocked too long: ${pauseElapsed}ms`); const fingerprintPublished = await waitUntil(() => sender.sent.some((entry) => ( entry.channel === "netcatty:transfer:progress" && /^sha256:[a-f0-9]{64}$/.test(entry.payload.sourceFingerprint || "") ))); assert.equal(fingerprintPublished, true, "full pause identity must be published after pause acknowledgement"); const fingerprintEntry = sender.sent.findLast((entry) => ( entry.channel === "netcatty:transfer:progress" && /^sha256:[a-f0-9]{64}$/.test(entry.payload.sourceFingerprint || "") )); assert.equal( fingerprintEntry?.payload.sourceFingerprint, `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`, ); { const resumed = await transferBridge.resumeTransfer(null, { transferId: "late-pause-race" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } } assert.equal((await running).error, undefined); }); test("remote restart verifies the complete source with bounded concurrent reads", async (t) => { const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("resume-window")}-`); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `resume-window-${crypto.randomUUID()}`; const payload = Buffer.alloc(2 * 1024 * 1024 + 17); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; const checkpoint = 512 * 1024; const targetPath = path.join(tempDir, "target.bin"); const stagePath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); await fs.promises.writeFile(stagePath, payload.subarray(0, checkpoint)); t.after(async () => { await fs.promises.unlink(stagePath).catch(() => {}); }); const sender = createSender(); let verificationReads = 0; let maxVerificationReads = 0; let verificationBytes = 0; const { sftp } = createPipelinedDownloadSftp(payload, { read(_handle, buffer, offset, length, position, callback) { const verifying = sender.sent.at(-1)?.payload.phase === "verifying"; if (verifying) { verificationReads += 1; maxVerificationReads = Math.max(maxVerificationReads, verificationReads); } setTimeout(() => { const bytes = Math.min(length, payload.length - position); payload.copy(buffer, offset, position, position + bytes); if (verifying) { verificationReads -= 1; verificationBytes += bytes; } callback(null, bytes); }, 2); }, }); const client = { sftp, stat: async () => ({ size: payload.length }) }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer({ sender }, { transferId, sourcePath: "/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, checkpointBytes: checkpoint, sourceFingerprint: `sha256:p${payload.length}:${crypto.createHash("sha256").update(payload).digest("hex")}`, }); assert.equal(result.error, undefined, result.error); assert.deepEqual(await fs.promises.readFile(targetPath), payload); assert.ok(maxVerificationReads > 1, "large resume verification must not wait for one network read at a time"); assert.ok(maxVerificationReads <= DOWNLOAD_TRANSFER_CONCURRENCY); assert.ok(verificationBytes >= payload.length, "verify every source byte, not a sample"); }); for (const scenario of ["read-error", "open-error", "changed-content", "cancelled", "timeout"]) { test(`remote restart prefix compatibility: ${scenario}`, async (t) => { const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("resume-compat")}-`); const transferId = `resume-compat-${crypto.randomUUID()}`; const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 71); const targetPath = path.join(tempDir, "target.bin"); const stagePath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagePath, { force: true }); }); await fs.promises.writeFile(stagePath, payload); let streamOpens = 0; const protocolError = new Error(scenario === "cancelled" ? "Transfer cancelled" : `Range ${scenario}`); protocolError.code = scenario === "cancelled" ? "ABORT_ERR" : scenario === "timeout" ? "SFTP_READ_TIMEOUT" : 4; if (scenario === "timeout") protocolError.sftpRequestTimedOut = true; const { sftp } = createPipelinedDownloadSftp(payload, { open(_path, _flags, callback) { if (scenario === "open-error") callback(protocolError); else callback(null, Buffer.from("prefix-handle")); }, read(_handle, _buffer, _offset, _length, _position, callback) { setImmediate(() => callback(protocolError)); }, createReadStream(_path, options) { streamOpens += 1; assert.equal(options.start, 0); assert.equal(options.end, payload.length - 1); const current = Buffer.from(payload); if (scenario === "changed-content") current[current.length - 1] ^= 1; return Readable.from([current]); }, }); transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: payload.length }), }]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath: "/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, checkpointBytes: payload.length, sourceFingerprint: `sha256:p${payload.length}:${crypto.createHash("sha256").update(payload).digest("hex")}`, }); if (scenario === "cancelled" || scenario === "timeout") { assert.match(result.error || "", /cancelled|Range timeout/); assert.equal(streamOpens, 0, "cancellation and timeouts must not start a fallback"); } else { assert.ok(streamOpens > 0, "ordinary protocol errors must retain stream compatibility"); if (scenario === "changed-content") { assert.match(result.error || "", /source.*changed|source.*match/i); assert.equal(fs.existsSync(targetPath), false, "fallback must still reject changed content"); } else { assert.equal(result.error, undefined, result.error); assert.deepEqual(await fs.promises.readFile(targetPath), payload); } } }); } test("remote prefix timeout abandons its channel and ignores late replies", async () => { const pending = []; let ended = false; let progress = 0; const { sftp } = createPipelinedDownloadSftp(Buffer.alloc(2 * TRANSFER_CHUNK_SIZE), { read(_handle, buffer, _offset, length, _position, callback) { pending.push(() => { buffer.fill(0); callback(null, length); }); }, end() { ended = true; }, createReadStream() { throw new Error("must not fall back after a timeout"); }, }); const client = { sftp }; await assert.rejects(transferBridge._hashRemotePrefixForTests( client, "source", "/source.bin", undefined, 2 * TRANSFER_CHUNK_SIZE, { sftpReadTimeoutMs: 20, onProgress: () => { progress += 1; } }, ), (error) => error.code === "SFTP_READ_TIMEOUT" && error.noTransferFallback === true); assert.equal(ended, true); assert.equal(client.sftp, null); assert.equal(pending.length, 2); for (const reply of pending) reply(); await new Promise((resolve) => setImmediate(resolve)); assert.equal(progress, 0, "finished verification must ignore late window progress"); }); test("remote prefix watchdog observes every positive short READ", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 73); let replies = 0; const { sftp } = createPipelinedDownloadSftp(payload, { read(_handle, buffer, offset, length, position, callback) { setTimeout(() => { const count = Math.min(1024, length); payload.copy(buffer, offset, position, position + count); replies += 1; callback(null, count); }, 5); }, createReadStream() { throw new Error("responsive range reads must not fall back"); }, }); const hashing = transferBridge._hashRemotePrefixForTests( { sftp }, "source", "/source.bin", undefined, payload.length, { sftpReadTimeoutMs: 25 }, ); // Handle rejection immediately so advancing the mock clock can expose a bad // watchdog without generating an unrelated unhandled-rejection failure. const outcome = hashing.then((digest) => ({ digest }), (error) => ({ error })); for (let index = 0; index < 40; index += 1) { await new Promise((resolve) => setImmediate(resolve)); t.mock.timers.tick(5); } const result = await outcome; assert.equal(result.error, undefined, result.error?.message); assert.equal(replies, 32); assert.equal(result.digest, crypto.createHash("sha256").update(payload).digest("hex")); }); test("remote resume identity rejects a same-size source rewrite", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-modifytime-id-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 11); for (let i = 0; i < payload.length; i += 1) payload[i] = i % 251; const modifyTimeMs = 1_700_000_000_000; const digest = crypto.createHash("sha256").update(payload).digest("hex"); const sourceFingerprint = `sha256:p${payload.length}:${digest}`; const checkpoint = TRANSFER_CHUNK_SIZE; const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath( "download-modifytime-id-resume", path.basename(targetPath), ); await fs.promises.mkdir(path.dirname(stagedPath), { recursive: true }); await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint)); // Same size, one byte past the saved prefix rewritten. const rewritten = Buffer.from(payload); rewritten[checkpoint + 4] = 90; const { sftp } = createPipelinedDownloadSftp(rewritten); const client = { sftp, stat() { return Promise.resolve({ size: rewritten.length, modifyTime: modifyTimeMs }); }, client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const restarted = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-modifytime-id-resume", sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, checkpointBytes: checkpoint, sourceFingerprint, }, ); assert.match(restarted.error || "", /source file has changed|saved content does not match/i); }); test("legacy sampled identity restarts safely instead of resuming mixed content", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-meta-rewrite-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const fileSize = 512 * 1024; const checkpoint = 400 * 1024; // Between mid sample (~240–272 KiB) and tail sample (~480–512 KiB). const rewriteAt = 300 * 1024; const modifyTimeMs = 1_700_000_000_000; const original = Buffer.alloc(fileSize, 17); const rewritten = Buffer.from(original); rewritten.fill(99, rewriteAt, rewriteAt + TRANSFER_CHUNK_SIZE); const transferId = "meta-rewrite-resume"; const targetPath = path.join(tempDir, "target.bin"); const stagePath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); await fs.promises.mkdir(path.dirname(stagePath), { recursive: true }); await fs.promises.writeFile(stagePath, original.subarray(0, checkpoint)); const { sftp } = createPipelinedDownloadSftp(rewritten, { // meta: fingerprints restart from zero; hash helpers may still sample. createReadStream(_remotePath, options = {}) { const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : rewritten.length - 1; return Readable.from(Buffer.from(rewritten.subarray(start, end + 1))); }, }); const client = { sftp, stat() { return Promise.resolve({ size: fileSize, modifyTime: modifyTimeMs }); }, client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: fileSize, resumable: true, checkpointBytes: checkpoint, sourceFingerprint: `meta:${fileSize}:${modifyTimeMs}`, }, ); assert.equal(result.error, undefined, result.error); assert.deepEqual(await fs.promises.readFile(targetPath), rewritten); }); test("resume rejects a rewrite beyond the first 256 KiB of the saved prefix", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-full-prefix-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `full-prefix-${crypto.randomUUID()}`; const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); const fileSize = 768 * 1024; const checkpoint = 400 * 1024; const original = Buffer.alloc(fileSize, 17); const current = Buffer.from(original); current.fill(99, 300 * 1024, 332 * 1024); await fs.promises.writeFile(sourcePath, current); await fs.promises.writeFile(stagedPath, original.subarray(0, checkpoint)); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); transferBridge.init({ sftpClients: new Map() }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: fileSize, resumable: true, checkpointBytes: checkpoint, sourceFingerprint: `sha256:${crypto.createHash("sha256").update(current).digest("hex")}`, }); assert.match(result.error || "", /saved content does not match/i); assert.equal(fs.existsSync(targetPath), false); }); test("local resume verification reports progress and destroys both readers on cancellation", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-verify-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `verify-cancel-${crypto.randomUUID()}`; const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); const checkpoint = 512 * 1024; const verifyBytes = checkpoint; const payload = Buffer.alloc(checkpoint + 1, 31); await fs.promises.writeFile(sourcePath, payload); await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint)); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); const sourceFingerprint = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`; const originalCreateReadStream = fs.createReadStream; const verificationStreams = []; fs.createReadStream = (filePath, options = {}) => { if ( (filePath === sourcePath || filePath === stagedPath) && options.start === 0 && options.end === verifyBytes - 1 ) { const stream = new PassThrough(); verificationStreams.push(stream); return stream; } return originalCreateReadStream(filePath, options); }; t.after(() => { fs.createReadStream = originalCreateReadStream; }); transferBridge.init({ sftpClients: new Map() }); const sender = createSender(); const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: payload.length, resumable: true, checkpointBytes: checkpoint, sourceFingerprint, }); t.after(async () => { await transferBridge.cancelTransfer(null, { transferId }); await running.catch(() => {}); transferBridge.clearPendingCancel(transferId); }); assert.equal(await waitUntil(() => verificationStreams.length === 2), true); await new Promise((resolve) => setTimeout(resolve, 220)); for (const stream of verificationStreams) stream.write(Buffer.alloc(256 * 1024, 31)); assert.equal(await waitUntil(() => sender.sent.some((entry) => ( entry.channel === "netcatty:transfer:progress" && entry.payload.phase === "verifying" && entry.payload.speed > 0 ))), true, "resume verification progress must be visible"); const verifyingEvent = sender.sent.findLast((entry) => entry.payload?.phase === "verifying"); assert.equal(verifyingEvent?.payload.transferred, checkpoint); assert.equal(verifyingEvent?.payload.checkpointBytes, checkpoint); await transferBridge.cancelTransfer(null, { transferId }); const result = await running; assert.match(result.error || "", /cancel/i); assert.equal(verificationStreams.every((stream) => stream.destroyed), true); }); test("remote resume verification destroys the SFTP reader on cancellation", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-remote-verify-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `remote-verify-cancel-${crypto.randomUUID()}`; const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); const checkpoint = 512 * 1024; const verifyBytes = checkpoint; const payload = Buffer.alloc(checkpoint + 1, 47); await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint)); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); const sourceFingerprint = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`; let verificationStream; const sftp = createFastSftp({ createReadStream(_remotePath, options = {}) { const start = Number(options.start) || 0; const end = Number.isFinite(options.end) ? options.end : payload.length - 1; if (start === 0 && end === verifyBytes - 1) { verificationStream = new PassThrough(); return verificationStream; } return Readable.from(payload.subarray(start, end + 1)); }, }); const client = { sftp, stat: async () => ({ size: payload.length }), }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, checkpointBytes: checkpoint, sourceFingerprint, }); t.after(async () => { await transferBridge.cancelTransfer(null, { transferId }); await running.catch(() => {}); transferBridge.clearPendingCancel(transferId); }); assert.equal(await waitUntil(() => verificationStream !== undefined), true); await new Promise((resolve) => setTimeout(resolve, 220)); verificationStream.write(Buffer.alloc(256 * 1024, 47)); assert.equal(await waitUntil(() => sender.sent.some((entry) => ( entry.channel === "netcatty:transfer:progress" && entry.payload.phase === "verifying" && entry.payload.speed > 0 ))), true); await transferBridge.cancelTransfer(null, { transferId }); const result = await running; assert.match(result.error || "", /cancel/i); assert.equal(verificationStream.destroyed, true); }); test("remote resume verification cancellation does not wait for SFTP channel reopen", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-remote-verify-reopen-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `remote-verify-reopen-${crypto.randomUUID()}`; const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); const checkpoint = 64 * 1024; const totalBytes = checkpoint + 1; await fs.promises.writeFile(stagedPath, Buffer.alloc(checkpoint, 19)); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); let reopenStarted = false; let finishReopen; const client = { sftp: null, stat: async () => ({ size: totalBytes }), client: { sftp(callback) { reopenStarted = true; finishReopen = () => callback(null, createFastSftp({})); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const running = transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes, resumable: true, checkpointBytes: checkpoint, sourceFingerprint: `sha256:${"0".repeat(64)}`, }); t.after(async () => { await transferBridge.cancelTransfer(null, { transferId }); await running.catch(() => {}); transferBridge.clearPendingCancel(transferId); }); assert.equal(await waitUntil(() => reopenStarted), true); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 250)), ]); assert.match(result.error || "", /cancel/i); finishReopen?.(); assert.equal(await waitUntil(() => client._reopeningPromise === null), true); }); test("failed resumable upload opens close their isolated channel", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-open-fail-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 23)); let endedChannels = 0; let hadOpenErrorListener = false; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { hadOpenErrorListener = fastSftp.listenerCount("error") > 0; const error = new Error("permission denied"); if (hadOpenErrorListener) fastSftp.emit("error", error); callback(error); }, end() { endedChannels += 1; }, }); const client = { // Stream fallback also rejects so the transfer still fails after cleanup. sftp: createFastSftp({ createWriteStream() { const writeStream = new Writable({ write(_chunk, _encoding, callback) { callback(new Error("permission denied")); }, }); queueMicrotask(() => writeStream.destroy(new Error("permission denied"))); return writeStream; }, }), client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-open-fail", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: 32 * 1024, resumable: true, }, ); assert.match(result.error || "", /permission denied/); assert.equal(hadOpenErrorListener, true); // concurrent-isolated + fastPut-isolated each open/end their own channel. assert.ok(endedChannels >= 1, `expected isolated channels to end, got ${endedChannels}`); }); test("SFTP upload does not create a local ranges.sha256 digest sidecar", async (t) => { // Size-based resume only — no whole-file content digest sidecar (industry default). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-no-digest-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(64 * 1024, 71); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const remoteFiles = new Map(); const fastSftp = createFastSftp({ open(remotePath, _flags, callback) { remoteFiles.set(String(remotePath), Buffer.alloc(payload.length)); callback(null, Buffer.from(String(remotePath))); }, write(handle, buffer, offset, length, position, callback) { buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length); callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat: async (remotePath) => ({ size: remoteFiles.get(String(remotePath))?.length || 0 }), rename(sourcePath, destinationPath) { remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath))); remoteFiles.delete(String(sourcePath)); return Promise.resolve(); }, delete: async () => {}, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const transferId = "upload-no-digest-sidecar"; const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined); const digestId = crypto.createHash("sha256") .update(transferId) .digest("hex") .slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); assert.equal(fs.existsSync(digestPath), false); assert.deepEqual(remoteFiles.get("/tmp/upload.bin"), payload); }); test("SFTP upload ignores leftover digest sidecars from older builds", async (t) => { // Older builds wrote ranges.sha256 next to the transfer id. Size-based uploads // must not require or recreate that sidecar. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-legacy-digest-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-legacy-digest"; const localPath = path.join(tempDir, "upload.bin"); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 41); await fs.promises.writeFile(localPath, payload); const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); await fs.promises.mkdir(path.dirname(digestPath), { recursive: true }); await fs.promises.writeFile(digestPath, Buffer.alloc(32, 9)); const remoteFiles = new Map(); const fastSftp = createFastSftp({ open(remotePath, _flags, callback) { remoteFiles.set(String(remotePath), Buffer.alloc(payload.length)); callback(null, Buffer.from(String(remotePath))); }, write(handle, buffer, offset, length, position, callback) { buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length); callback(null); }, close(_handle, callback) { callback(null); }, }); transferBridge.init({ sftpClients: new Map([["target", { sftp: createFastSftp({}), stat: async (p) => ({ size: remoteFiles.get(String(p))?.length || 0 }), rename: async (src, dst) => { remoteFiles.set(String(dst), remoteFiles.get(String(src))); remoteFiles.delete(String(src)); }, delete: async () => {}, client: { sftp: (callback) => callback(null, fastSftp) }, }]]), }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, }, ); assert.equal(result.error, undefined); // Legacy sidecar may still exist on disk; the upload must not depend on it. assert.deepEqual(remoteFiles.get("/tmp/upload.bin"), payload); }); test("digest baseline cancellation removes the sidecar before opening remote upload", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-digest-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-digest-cancel"; const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 53)); const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); t.after(async () => fs.promises.rm(digestPath, { force: true })); let remoteChannelOpens = 0; const client = { sftp: createFastSftp({}), delete: async () => {}, client: { sftp(callback) { remoteChannelOpens += 1; callback(new Error("remote must not open")); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const originalOpen = fs.promises.open; let cancellationTriggered = false; fs.promises.open = async (filePath, flags, ...args) => { const handle = await originalOpen(filePath, flags, ...args); if (String(filePath) !== localPath || flags !== "r" || cancellationTriggered) return handle; return { async read(...readArgs) { cancellationTriggered = true; await transferBridge.cancelTransfer(null, { transferId }); return handle.read(...readArgs); }, close: () => handle.close(), }; }; let result; try { result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, resumable: true, }, ); } finally { fs.promises.open = originalOpen; } // Local source open is hooked to cancel mid-read. Depending on whether the // background baseline or the first upload range opens first, cancel may come // from that hook or from the forced remote open failure. assert.ok( cancellationTriggered || /cancel|remote must not open/i.test(result.error || ""), `expected cancel or remote-open failure, got error=${result.error} cancelled=${cancellationTriggered}`, ); assert.ok(result.error, "transfer must fail closed"); }); test("failed local open for resumable upload still ends the isolated channel", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-local-open-fail-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Create the source so preflight can proceed, then delete it as soon as the // isolated channel opens so concurrent isolated upload fails on local open. const localPath = path.join(tempDir, "source.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 7)); let endedChannels = 0; let remoteOpenAttempts = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { remoteOpenAttempts += 1; callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, end() { endedChannels += 1; }, }); const client = { sftp: createFastSftp({ createWriteStream() { // Stream fallback after range-path failure — keep it error-safe. const writeStream = new Writable({ write(_chunk, _encoding, callback) { callback(new Error("stream fallback after missing local open")); }, }); return writeStream; }, }), client: { sftp(callback) { fs.promises.rm(localPath, { force: true }).finally(() => { callback(null, fastSftp); }); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-local-open-fail", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: 32 * 1024, resumable: true, skipAdmission: true, }, ); assert.ok(result.error, "expected transfer to fail when local source disappears"); // Critical: isolated channel must not leak when local open fails first. assert.ok(endedChannels >= 1, `expected isolated channel end, got ${endedChannels}`); assert.equal(remoteOpenAttempts, 0); }); test("cancel during stalled resumable upload OPEN ends the isolated channel", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-open-stall-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 29)); let endedChannels = 0; let releaseOpen = null; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { releaseOpen = () => callback(new Error("channel closed during open")); }, end() { endedChannels += 1; // Simulate ssh2 failing the pending OPEN when the channel ends. releaseOpen?.(); releaseOpen = null; }, }); const client = { sftp: createFastSftp({ createWriteStream() { const writeStream = new Writable({ write(_chunk, _encoding, callback) { callback(new Error("should not stream-fallback after cancel")); }, }); return writeStream; }, }), client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId: "upload-open-stall-cancel", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: 32 * 1024, resumable: true, skipAdmission: true, }, ); const readyDeadline = Date.now() + 1000; while (!releaseOpen && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.ok(releaseOpen, "expected remote OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId: "upload-open-stall-cancel" }); const result = await running; assert.match(result.error || "", /cancel|closed/i); assert.ok(endedChannels >= 1, `expected isolated channel end on cancel, got ${endedChannels}`); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); }); test("resumable SFTP uploads fail closed when pipelined strategies fail (no serial stream)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.from("complete fallback upload"); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let endedChannels = 0; let createWriteStreamCalls = 0; let deleteCalls = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("server rejected random-access writes")); }, end() { endedChannels += 1; }, }); const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("server rejected random-access writes")); }, createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not run"); }, }), stat() { return Promise.resolve({ size: 0 }); }, rename() { return Promise.resolve(); }, delete() { deleteCalls += 1; return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-fail-closed", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined upload failed|rejected random-access/i); assert.equal(createWriteStreamCalls, 0); assert.ok(endedChannels >= 1, `expected isolated channels to end, got ${endedChannels}`); assert.equal(deleteCalls, 0, "a resumable upload error must preserve its staged prefix"); }); test("resumable concurrent uploads reject a source that shrinks mid-transfer", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-source-change-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Size-based finish check (no whole-file digest). A same-size rewrite is not // re-hashed; shrink/grow still fails promotion so the remote .part is cleaned. const payload = Buffer.alloc(16 * 1024, 41); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let shrunk = false; let promoted = false; let stagedDeleted = false; let remoteBytes = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { remoteBytes = Math.max(remoteBytes, position + length); if (!shrunk && position === 0) { shrunk = true; fs.writeFileSync(localPath, Buffer.alloc(payload.length / 2, 42)); } callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: remoteBytes }); }, rename() { promoted = true; return Promise.resolve(); }, delete() { stagedDeleted = true; return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-source-change", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.equal(shrunk, true); assert.match(result.error || "", /source|size|changed/i); assert.equal(promoted, false); assert.equal(stagedDeleted, true); }); test("assertSourceMetadataUnchanged ignores ctime drift when content is verified separately", () => { const initial = { size: 100, mtimeMs: 1, ctimeMs: 1, mtime: 0.001, ctime: 0.001, ino: 42, }; const drifted = { ...initial, ctimeMs: 999, ctime: 0.999, }; // Without a separate content proof, timestamp drift is a hard fail (download path). assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests(initial, drifted, 100), /source content changed/i, ); // Upload finish soft path: ignore ctime noise but still enforce mtime/ino. assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests( initial, drifted, 100, { ignoreCtime: true }, )); assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...drifted, mtimeMs: 2, mtime: 0.002 }, 100, { ignoreCtime: true }, ), /source content changed/i, ); // With digest / per-range verification, macOS xattr ctime bumps must not abort. assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests( initial, drifted, 100, { contentVerifiedSeparately: true }, )); // Size still fails hard even when content is verified separately. assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...drifted, size: 99 }, 100, { contentVerifiedSeparately: true }, ), /source size changed/i, ); // Append-only growth is rejected unless the download snapshot opts in. assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...initial, size: 150, mtimeMs: 2 }, 100, ), /source size changed/i, ); // Growth without a separate prefix proof is still rejected (rewrite+grow risk). assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...initial, size: 150, mtimeMs: 2, ctimeMs: 2 }, 100, { allowSourceGrowth: true }, ), /source content changed/i, ); assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...initial, size: 150, mtimeMs: 2, ctimeMs: 2 }, 100, { allowSourceGrowth: true, contentVerifiedSeparately: true }, )); // Growth still cannot cover a shrink. assert.throws( () => transferBridge._assertSourceMetadataUnchangedForTests( initial, { ...initial, size: 80 }, 100, { allowSourceGrowth: true, contentVerifiedSeparately: true }, ), /source size changed/i, ); }); test("resumable upload succeeds when only source ctime drifts (pause/resume false positive)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-ctime-drift-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Reproduce the user-facing finish-path false positive: content bytes are // stable (digest matches) but ctime moved — common on macOS after long // pause/resume cycles (Spotlight / quarantine / xattr). const payload = Buffer.alloc(16 * 1024, 43); const localPath = path.join(tempDir, "ChatGPT.dmg"); await fs.promises.writeFile(localPath, payload); const baseline = await fs.promises.stat(localPath); let allowCtimeDrift = false; const driftedStat = () => { if (!allowCtimeDrift) return baseline; return { ...baseline, ctimeMs: baseline.ctimeMs + 60_000, ctime: (baseline.ctimeMs + 60_000) / 1000, // mtime intentionally stable — content not rewritten. }; }; const realStat = fs.promises.stat.bind(fs.promises); const realOpen = fs.promises.open.bind(fs.promises); fs.promises.stat = async (p, ...args) => { if (path.resolve(String(p)) === path.resolve(localPath)) return driftedStat(); return realStat(p, ...args); }; fs.promises.open = async (p, flags, ...args) => { const handle = await realOpen(p, flags, ...args); if (path.resolve(String(p)) === path.resolve(localPath) && String(flags).includes("r")) { handle.stat = async () => driftedStat(); } return handle; }; t.after(() => { fs.promises.stat = realStat; fs.promises.open = realOpen; }); let remoteBytes = 0; let promoted = false; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { remoteBytes = Math.max(remoteBytes, position + length); // After the first remote WRITE, simulate metadata-only drift as if the // transfer had been pause/resumed for a long wall-clock time. allowCtimeDrift = true; callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: remoteBytes }); }, rename() { promoted = true; return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-ctime-drift-ok", sourcePath: localPath, targetPath: "/tmp/ChatGPT.dmg", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined, `expected success, got: ${result.error}`); assert.equal(allowCtimeDrift, true); assert.equal(promoted, true); assert.equal(remoteBytes, payload.length); }); test("non-resumable shared range uploads stream without a content digest", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-stream-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(16 * 1024, 51); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let remoteBytes = 0; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { remoteBytes = Math.max(remoteBytes, position + length); callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat: async () => ({ size: remoteBytes }), rename: async () => {}, delete: async () => {}, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-nonresume-stream", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); assert.equal(result.error, undefined); assert.equal(remoteBytes, payload.length); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${crypto.createHash("sha256").update("upload-nonresume-stream").digest("hex").slice(0, 16)}`, "ranges.sha256", ); assert.equal(fs.existsSync(digestPath), false); }); test("non-resumable isolated upload streams without a content digest sidecar", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-isolated-stream-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(16 * 1024, 53); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload.bin"; const remoteFiles = new Map(); await fs.promises.writeFile(localPath, payload); let writeCalls = 0; let promoted = false; const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, }); const fastSftp = createFastSftp({ open(remotePath, _flags, callback) { const key = String(remotePath); remoteFiles.set(key, Buffer.alloc(payload.length)); callback(null, Buffer.from(key)); }, write(handle, buffer, offset, length, position, callback) { writeCalls += 1; buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length); callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: sharedSftp, stat(remotePath) { const value = remoteFiles.get(String(remotePath)); if (!value) { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); } return Promise.resolve({ size: value.length }); }, rename(sourcePath, destinationPath) { promoted = true; remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath))); remoteFiles.delete(String(sourcePath)); return Promise.resolve(); }, delete: async () => {}, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-isolated-stream", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); assert.equal(result.error, undefined); assert.ok(writeCalls > 0); assert.equal(promoted, true); }); test("fastPut streams the live local path without a content snapshot", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-live-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(16 * 1024, 55); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old target")]]); await fs.promises.writeFile(localPath, payload); let fastPutSourcePath = null; const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const value = remoteFiles.get(String(remotePath)); if (!value) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: value.length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, }); const fastSftp = createFastSftp({ fastPut(sourcePath, remotePath, options, callback) { fastPutSourcePath = sourcePath; const uploaded = fs.readFileSync(sourcePath); remoteFiles.set(String(remotePath), uploaded); options.step?.(uploaded.length, uploaded.length, uploaded.length); queueMicrotask(() => callback(null)); }, }); const client = { sftp: sharedSftp, stat: async (remotePath) => ({ size: remoteFiles.get(String(remotePath))?.length || 0 }), chmod: async () => {}, rename(sourcePath, destinationPath) { const source = String(sourcePath); const destination = String(destinationPath); remoteFiles.set(destination, remoteFiles.get(source)); remoteFiles.delete(source); return Promise.resolve(); }, delete(remotePath) { remoteFiles.delete(String(remotePath)); return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-fastput-live", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); assert.equal(result.error, undefined); assert.equal(fastPutSourcePath, localPath); assert.deepEqual(remoteFiles.get(targetPath), payload); }); test("failed local source open for non-resumable upload fails closed without digest sidecars", async (t) => { // Size-based uploads no longer create ranges.sha256 / snapshot.bin. A missing // or unreadable local source must still fail closed and leave no temp digest. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-local-open-fail-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const localPath = path.join(tempDir, "upload.bin"); const payload = Buffer.alloc(16 * 1024, 59); await fs.promises.writeFile(localPath, payload); const transferId = "upload-local-open-fail"; const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); const snapshotPath = tempDirBridge.getTransferTempFilePath( `upload-source-${digestId}`, "snapshot.bin", ); let fastPutCalls = 0; let remoteOpens = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { remoteOpens += 1; callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, fastPut(_sourcePath, _remotePath, _options, callback) { fastPutCalls += 1; callback(null); }, }); const client = { sftp: createFastSftp({}), delete: async () => {}, client: { sftp: (callback) => callback(null, fastSftp) }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const realOpen = fs.promises.open.bind(fs.promises); fs.promises.open = async (filePath, flags, ...args) => { if (path.resolve(String(filePath)) === path.resolve(localPath) && String(flags).includes("r")) { throw new Error("local source unreadable"); } return realOpen(filePath, flags, ...args); }; t.after(() => { fs.promises.open = realOpen; }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }); assert.match(result.error || "", /local source unreadable|ENOENT|not found|source/i); assert.equal(fastPutCalls, 0); assert.equal(fs.existsSync(digestPath), false); assert.equal(fs.existsSync(snapshotPath), false); }); test("non-resumable shared range uploads remove their temporary digest after success", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-cleanup-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-nonresume-cleanup"; const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 61); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); let remoteBytes = 0; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { remoteBytes = Math.max(remoteBytes, position + length); callback(null); }, close(_handle, callback) { callback(null); }, }); transferBridge.init({ sftpClients: new Map([["target", { __netcattySudoMode: true, sftp: sharedSftp, stat: async () => ({ size: remoteBytes }), rename: async () => {}, delete: async () => {}, }]]), }); const sender = createSender(); const result = await transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); assert.equal(result.error, undefined); assert.equal(fs.existsSync(digestPath), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); }); test("non-resumable shared range uploads reject a source that grows and remove their digest", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-growth-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-nonresume-growth"; const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 62); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); let sourceGrew = false; let remoteBytes = 0; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { remoteBytes = Math.max(remoteBytes, position + length); if (!sourceGrew) { sourceGrew = true; fs.appendFileSync(localPath, Buffer.from([1])); } callback(null); }, close(_handle, callback) { callback(null); }, }); transferBridge.init({ sftpClients: new Map([["target", { __netcattySudoMode: true, sftp: sharedSftp, stat: async () => ({ size: remoteBytes }), }]]), }); const sender = createSender(); const result = await transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); assert.equal(sourceGrew, true); assert.match(result.error || "", /source size changed/i); assert.equal(fs.existsSync(digestPath), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("non-resumable digest creation cancellation removes the temporary digest", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-digest-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-nonresume-digest-cancel"; const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile( localPath, Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 63), ); const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); let remoteOpenAttempts = 0; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { remoteOpenAttempts += 1; callback(new Error("remote must not open")); }, write(_handle, _buffer, _offset, _length, _position, callback) { callback(new Error("remote must not write")); }, }); transferBridge.init({ sftpClients: new Map([["target", { __netcattySudoMode: true, sftp: sharedSftp, stat: async () => ({ size: 0 }), }]]), }); const originalOpen = fs.promises.open; let cancellationTriggered = false; let result; try { fs.promises.open = async (filePath, flags, ...args) => { const handle = await originalOpen(filePath, flags, ...args); if (String(filePath) !== localPath || flags !== "r" || cancellationTriggered) return handle; return { async read(...readArgs) { cancellationTriggered = true; await transferBridge.cancelTransfer(null, { transferId }); return handle.read(...readArgs); }, stat: () => handle.stat(), close: () => handle.close(), }; }; result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, resumable: false, }, ); } finally { fs.promises.open = originalOpen; } assert.ok( cancellationTriggered || /cancel|remote must not/i.test(result.error || ""), `expected cancel path, got error=${result.error}`, ); assert.ok(result.error, "transfer must fail closed"); // Remote OPEN may start while the local digest baseline is still scanning. }); test("non-resumable shared range cancellation drains in-flight writes", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-write-cancel-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const transferId = "upload-nonresume-write-cancel"; const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 64); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const pendingWrites = []; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { pendingWrites.push(callback); }, close(_handle, callback) { callback(null); }, }); transferBridge.init({ sftpClients: new Map([["target", { __netcattySudoMode: true, sftp: sharedSftp, stat: async () => ({ size: 0 }), rename: async () => {}, delete: async () => {}, }]]), }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, }, ); const readyDeadline = Date.now() + 2000; while ( pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline ) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY); let transferSettled = false; void running.finally(() => { transferSettled = true; }); await transferBridge.cancelTransfer(null, { transferId }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(transferSettled, false); const firstWrite = pendingWrites.shift(); assert.ok(pendingWrites.length > 0); firstWrite(new Error("write cancelled")); await new Promise((resolve) => setImmediate(resolve)); assert.equal(transferSettled, false); const finalWrite = pendingWrites.pop(); for (const callback of pendingWrites.splice(0)) callback(new Error("write cancelled")); await new Promise((resolve) => setTimeout(resolve, 2200)); assert.equal(transferSettled, false); finalWrite(new Error("write cancelled")); const result = await running; assert.match(result.error || "", /cancel|write cancelled/i); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); }); test("resumable fast uploads fail closed when isolated channel errors (no serial stream)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-channel-error-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(32 * 1024, 31); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let hadErrorListener = false; let createWriteStreamCalls = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { hadErrorListener = fastSftp.listenerCount("error") > 0; const error = new Error("isolated channel failed"); queueMicrotask(() => { if (hadErrorListener) fastSftp.emit("error", error); callback(error); }); }, close(_handle, callback) { callback(null); }, end() {}, }); const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("shared open also fails")); }, createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not run"); }, }), stat() { return Promise.resolve({ size: 0 }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-channel-error", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined upload failed|isolated channel failed/i); assert.equal(hadErrorListener, true); assert.equal(createWriteStreamCalls, 0); }); test("resumable concurrent range failure does not complete via serial stream", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-sparse-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * 32 * 1024, 41); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let secondWriteCallback = null; let thirdWriteCompleted = false; let secondWriteFailureScheduled = false; const scheduleSecondWriteFailure = () => { if (secondWriteFailureScheduled || !thirdWriteCompleted || !secondWriteCallback) return; secondWriteFailureScheduled = true; const callback = secondWriteCallback; secondWriteCallback = null; queueMicrotask(() => callback(new Error("second range failed"))); }; let createWriteStreamCalls = 0; let remoteBytes = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { if (position === 32 * 1024) { secondWriteCallback = callback; scheduleSecondWriteFailure(); return; } remoteBytes = Math.max(remoteBytes, position + length); callback(null); if (position === 2 * 32 * 1024) { thirdWriteCompleted = true; scheduleSecondWriteFailure(); } }, close(_handle, callback) { callback(null); }, end() {}, }); const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("shared open also fails")); }, createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not run"); }, }), stat() { return Promise.resolve({ size: remoteBytes }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-sparse-fail-closed", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined upload failed|second range failed/i); assert.equal(createWriteStreamCalls, 0); }); test("resumable fast uploads reject a source that grows during transfer", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-source-growth-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(32 * 1024, 59); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let sourceGrew = false; let promoted = false; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { if (!sourceGrew) { sourceGrew = true; fs.appendFileSync(localPath, Buffer.from([1])); } callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: payload.length }); }, rename() { promoted = true; return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-source-growth", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /source size changed/); assert.equal(promoted, false); }); test("resumable uploads are size-based and do not require content digests", async (t) => { // WinSCP/FileZilla resume model: checkpoint = durable stage size. Source // rewrites mid-transfer are not blocked by a whole-file SHA baseline. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-size-resume-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc( (UPLOAD_TRANSFER_CONCURRENCY + 8) * TRANSFER_CHUNK_SIZE, 73, ); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let writeCalls = 0; let promoted = false; const remoteFiles = new Map(); const fastSftp = createFastSftp({ open(remotePath, _flags, callback) { remoteFiles.set(String(remotePath), Buffer.alloc(payload.length)); callback(null, Buffer.from(String(remotePath))); }, write(handle, buffer, offset, length, position, callback) { writeCalls += 1; buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length); callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat(remotePath) { return Promise.resolve({ size: remoteFiles.get(String(remotePath))?.length || 0 }); }, rename(sourcePath, destinationPath) { promoted = true; remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath))); remoteFiles.delete(String(sourcePath)); return Promise.resolve(); }, delete() { return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-size-based", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined); assert.ok(writeCalls > 0); assert.equal(promoted, true); const digestId = crypto.createHash("sha256") .update("upload-size-based") .digest("hex") .slice(0, 16); const digestPath = tempDirBridge.getTransferTempFilePath( `upload-digest-${digestId}`, "ranges.sha256", ); assert.equal(fs.existsSync(digestPath), false); }); test("resumable fast downloads clear staged data after a same-second source change", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-source-change-")); const transferId = "download-source-change"; const targetPath = path.join(tempDir, "download.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }); }); await fs.promises.writeFile(targetPath, "original"); const payload = Buffer.alloc(32 * 1024, 61); const latestPayload = Buffer.alloc(payload.length, 62); let reads = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { const currentPayload = reads++ === 0 ? payload : latestPayload; currentPayload.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); }, close(_handle, callback) { callback(new Error("remote close failed")); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: payload.length, mtime: 1 }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /source.*changed/); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original"); await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" }); }); test("growing-download prefix verification prefers a bounded remote digest", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(128 * 1024, 71); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const expectedDigest = crypto.createHash("sha256").update(payload).digest("hex"); let command = ""; const client = { sftp: createFastSftp({ createReadStream() { throw new Error("SFTP prefix stream should not run when remote digest is available"); }, }), client: { exec(request, callback) { command = request; const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.destroy = () => {}; callback(null, stream); setImmediate(() => { stream.emit("data", Buffer.from(`${expectedDigest} -\n`)); stream.emit("close", 0); }); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, }, ); assert.match(command, new RegExp(`head -c ${payload.length}`)); assert.match(command, /sha256sum|busybox|openssl/); }); test("growing-download prefix verification skips unprivileged digest for sudo SFTP", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-sudo-digest-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(96 * 1024, 72); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const emptyDigest = crypto.createHash("sha256").update("").digest("hex"); let execCalls = 0; const pipelined = createPipelinedDownloadSftp(payload, { createReadStream() { throw new Error("serial prefix stream should remain the final fallback"); }, }); const client = { __netcattySudoMode: true, sftp: pipelined.sftp, client: { exec(_request, callback) { execCalls += 1; const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.destroy = () => {}; callback(null, stream); // Unprivileged head fails open into sha256sum and still exits 0. setImmediate(() => { stream.emit("data", Buffer.from(`${emptyDigest} -\n`)); stream.emit("close", 0); }); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/root/secure.log", signal: new AbortController().signal, }, ); assert.equal(execCalls, 0, "sudo downloads must not use unprivileged digest commands"); }); test("growing-download prefix verification falls back after digest command timeout", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-timeout-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(96 * 1024, 69); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); let rangeReads = 0; const pipelined = createPipelinedDownloadSftp(payload, { read(handle, buffer, offset, length, position, callback) { rangeReads += 1; const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, createReadStream() { throw new Error("serial prefix stream should remain the final fallback"); }, }); const client = { sftp: pipelined.sftp, client: { exec(_request, callback) { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.destroy = () => {}; callback(null, stream); // Open succeeds but never completes — hits SSH_EXEC_RUN_TIMEOUT. }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, sshDigestRunTimeoutMs: 30, }, ); assert.ok(rangeReads > 0, "digest run timeout must fall through to SFTP range hashing"); }); test("growing-download prefix verification propagates digest open timeouts", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-open-timeout-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(32 * 1024, 68); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); let rangeReads = 0; const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { rangeReads += 1; callback(null, Buffer.from("should-not-open")); }, read() { throw new Error("SFTP fallback must not run after exec-open timeout"); }, createReadStream() { throw new Error("SFTP stream fallback must not run after exec-open timeout"); }, }), client: { // Never invoke the exec callback — hits SSH_EXEC_OPEN_TIMEOUT and // invalidates the transport in boundedSshExec. exec() {}, }, }; await assert.rejects( transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, sshDigestOpeningTimeoutMs: 30, }, ), (error) => error?.code === "SSH_EXEC_OPEN_TIMEOUT" && error?.noTransferFallback === true, ); assert.equal(rangeReads, 0, "open timeout must not fall through onto a poisoned session"); assert.equal(client.sftp, null, "exec-open timeout must drop the cached SFTP channel"); }); test("growing-download prefix verification rejects empty remote digests", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-empty-digest-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(64 * 1024, 70); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const emptyDigest = crypto.createHash("sha256").update("").digest("hex"); let rangeReads = 0; const pipelined = createPipelinedDownloadSftp(payload, { read(handle, buffer, offset, length, position, callback) { rangeReads += 1; const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, createReadStream() { throw new Error("serial prefix stream should remain the final fallback"); }, }); const client = { sftp: pipelined.sftp, client: { exec(_request, callback) { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.destroy = () => {}; callback(null, stream); setImmediate(() => { stream.emit("data", Buffer.from(`${emptyDigest} -\n`)); stream.emit("close", 0); }); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, }, ); assert.ok(rangeReads > 0, "empty digest must fall through to elevated/SFTP hashing"); }); test("growing-download prefix verification falls back to parallel SFTP reads", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-ranges-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(256 * 1024, 73); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const pipelined = createPipelinedDownloadSftp(payload, { createReadStream() { throw new Error("serial prefix stream should be the final fallback only"); }, }); const client = { sftp: pipelined.sftp, client: { exec(_request, callback) { const error = new Error("SSH exec unavailable"); error.code = "SSH_EXEC_UNAVAILABLE"; callback(error); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, }, ); }); test("growing-download SFTP range hashing keeps a bounded read window", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-bound-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const chunkSize = TRANSFER_CHUNK_SIZE; const concurrency = DOWNLOAD_TRANSFER_CONCURRENCY; const payload = Buffer.alloc((concurrency + 8) * chunkSize, 77); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); let maxIndexStarted = -1; let releaseFirstWindow; const firstWindowBlocked = new Promise((resolve) => { releaseFirstWindow = resolve; }); let firstWindowGateOpened = false; const pendingFirstWindow = []; const warnings = []; const onWarning = (warning) => { warnings.push(String(warning?.name || warning?.message || warning)); }; process.on("warning", onWarning); t.after(() => process.off("warning", onWarning)); const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("bounded-handle")); }, read(_handle, buffer, offset, length, position, callback) { const index = Math.floor(position / chunkSize); maxIndexStarted = Math.max(maxIndexStarted, index); const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); const deliver = () => { slice.copy(buffer, offset); callback(null, slice.length); }; if (!firstWindowGateOpened && index < concurrency) { pendingFirstWindow.push(deliver); if (pendingFirstWindow.length === 1) { firstWindowBlocked.then(() => { firstWindowGateOpened = true; for (const pending of pendingFirstWindow.splice(0)) pending(); }); } return; } setImmediate(deliver); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial prefix stream should not run for bounded-window test"); }, }), client: { exec(_request, callback) { const error = new Error("SSH exec unavailable"); error.code = "SSH_EXEC_UNAVAILABLE"; callback(error); }, }, }; const verifyPromise = transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, }, ); await waitUntil(() => pendingFirstWindow.length >= concurrency, 2000); assert.equal( maxIndexStarted, concurrency - 1, "later windows must wait until the current concurrency window finishes", ); releaseFirstWindow(); await verifyPromise; assert.ok(maxIndexStarted >= concurrency, "verification must continue past the first window"); assert.equal( warnings.some((message) => /MaxListenersExceededWarning/i.test(message)), false, "shared abort gate must not attach one listener per concurrent READ", ); }); test("growing-download SFTP range hashing resets inactivity on window progress", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-window-watchdog-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const chunkSize = TRANSFER_CHUNK_SIZE; const payload = Buffer.alloc(3 * chunkSize, 79); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const pendingReads = []; let pumping = false; const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("watchdog-handle")); }, read(_handle, buffer, offset, length, position, callback) { const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); pendingReads.push(() => { slice.copy(buffer, offset); callback(null, slice.length); }); if (!pumping && pendingReads.length >= 3) { pumping = true; // Serialize completions so wall time exceeds the inactivity budget, // but keep landing progress so a window watchdog stays armed. void (async () => { while (pendingReads.length > 0) { await new Promise((resolve) => setTimeout(resolve, 40)); pendingReads.shift()?.(); } })(); } }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial prefix stream should not hide window-watchdog behavior"); }, }), client: { exec(_request, callback) { const error = new Error("SSH exec unavailable"); error.code = "SSH_EXEC_UNAVAILABLE"; callback(error); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, // 80ms per-request deadlines would kill the 3rd serialized read (~120ms), // but window inactivity resets on each completion. sftpReadTimeoutMs: 80, }, ); }); test("growing-download SFTP range hashing drops channel when CLOSE stalls", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-close-stall-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(64 * 1024, 81); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const client = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("close-stall-handle")); }, read(_handle, buffer, offset, length, position, callback) { const end = Math.min(position + length, payload.length); const slice = payload.subarray(position, end); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close() { // Never invoke the callback — CLOSE watchdog must abandon the channel. }, createReadStream() { throw new Error("serial prefix stream should not run for CLOSE-stall test"); }, }), client: { exec(_request, callback) { const error = new Error("SSH exec unavailable"); error.code = "SSH_EXEC_UNAVAILABLE"; callback(error); }, }, }; await transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, sftpCloseTimeoutMs: 20, }, ); assert.equal(client.sftp, null, "stalled CLOSE must drop the wedged SFTP channel"); }); test("growing-download prefix verification times out a stalled SFTP READ", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-timeout-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(64 * 1024, 74); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const client = { sftp: createFastSftp({ open(_path, _flags, callback) { callback(null, Buffer.from("stalled-handle")); }, read() {}, createReadStream() { throw new Error("serial prefix stream should not hide a stalled range"); }, close(_handle, callback) { callback(null); }, }), }; await assert.rejects( transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, sftpReadTimeoutMs: 20, }, ), (error) => ( /SFTP READ timed out/.test(error?.message || "") && error?.sftpRequestTimedOut === true && error?.noTransferFallback === true ), ); assert.equal(client.sftp, null, "timed-out verification must drop the wedged SFTP channel"); }); test("growing-download prefix stream fallback times out without data", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-stream-timeout-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(32 * 1024, 75); const localPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(localPath, payload); const client = { sftp: createFastSftp({ createReadStream() { return new Readable({ read() {} }); }, }), }; await assert.rejects( transferBridge._assertDownloadSourceAfterTransferForTests( { size: payload.length, mtimeMs: 1 }, { size: payload.length + 1, mtimeMs: 2 }, payload.length, { localPath, client, remotePath: "/var/log/app.log", signal: new AbortController().signal, sftpReadTimeoutMs: 20, preferSftpRanges: false, }, ), (error) => ( /SFTP stream timed out/.test(error?.message || "") && error?.sftpRequestTimedOut === true && error?.noTransferFallback === true ), ); assert.equal(client.sftp, null, "stream timeout must drop the wedged SFTP channel"); }); test("resumable SFTP downloads succeed when the remote source only grows (live logs)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-growth-")); const transferId = "download-source-growth"; const targetPath = path.join(tempDir, "download.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }).catch(() => {}); }); // Snapshot at transfer start; remote appends more bytes while ranges run. const snapshot = Buffer.alloc(64 * 1024, 71); const grownTail = Buffer.alloc(8 * 1024, 72); let remoteSize = snapshot.length; let mtimeMs = 1_000; const remotePayload = () => Buffer.concat([snapshot, Buffer.alloc(Math.max(0, remoteSize - snapshot.length), 72)]); const serveReadStream = (_remotePath, options = {}) => { const current = remotePayload(); const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : current.length - 1; return Readable.from([Buffer.from(current.subarray(start, end + 1))]); }; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { // First range completion simulates an append-only log writer. if (remoteSize === snapshot.length) { remoteSize = snapshot.length + grownTail.length; mtimeMs += 1; } const current = remotePayload(); current.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); }, close(_handle, callback) { callback(null); }, createReadStream: serveReadStream, }); const client = { // Shared session is used for post-transfer prefix verification on growth. sftp: createFastSftp({ createReadStream: serveReadStream }), stat() { return Promise.resolve({ size: remoteSize, mtimeMs, ctimeMs: mtimeMs, mtime: mtimeMs / 1000, ctime: mtimeMs / 1000, }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/var/log/app.log", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: snapshot.length, resumable: true, }, ); assert.equal(result.error, undefined, result.error); assert.equal(remoteSize, snapshot.length + grownTail.length); const downloaded = await fs.promises.readFile(targetPath); assert.equal(downloaded.length, snapshot.length); assert.deepEqual(downloaded, snapshot); await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" }); }); test("resumable SFTP downloads reject growth when the planned prefix was rewritten", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-rewrite-growth-")); const transferId = "download-source-rewrite-growth"; const targetPath = path.join(tempDir, "download.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }).catch(() => {}); }); // Mid-transfer rotate: prefix bytes change and the file also grows. const snapshot = Buffer.alloc(64 * 1024, 71); const rewritten = Buffer.alloc(64 * 1024, 81); const grownTail = Buffer.alloc(8 * 1024, 72); let useRewritten = false; let remoteSize = snapshot.length; let mtimeMs = 1_000; const remotePayload = () => { const head = useRewritten ? rewritten : snapshot; if (remoteSize <= head.length) return Buffer.from(head.subarray(0, remoteSize)); return Buffer.concat([head, Buffer.alloc(remoteSize - head.length, 72)]); }; const serveReadStream = (_remotePath, options = {}) => { const current = remotePayload(); const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : current.length - 1; return Readable.from([Buffer.from(current.subarray(start, end + 1))]); }; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { // After half the snapshot is staged from the original prefix, rotate the // remote file in place and grow it so finish-path growth acceptance is // the only remaining safety net if samples miss the hole. if (!useRewritten && position >= snapshot.length / 2) { useRewritten = true; remoteSize = snapshot.length + grownTail.length; mtimeMs += 1; } const current = remotePayload(); current.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); }, close(_handle, callback) { callback(null); }, createReadStream: serveReadStream, }); const client = { sftp: createFastSftp({ createReadStream: serveReadStream }), stat() { return Promise.resolve({ size: remoteSize, mtimeMs, ctimeMs: mtimeMs, mtime: mtimeMs / 1000, ctime: mtimeMs / 1000, }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/var/log/app.log", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: snapshot.length, resumable: true, }, ); // Sample verify or full planned-prefix proof must reject the mixed file. assert.match(result.error || "", /source|content|changed/i); await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" }); if (fs.existsSync(targetPath)) { assert.notEqual((await fs.promises.stat(targetPath)).size, snapshot.length); } }); test("checkpoint-complete resume verifies growth with bounded prefix ranges", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-complete-checkpoint-")); const transferId = "download-complete-checkpoint-growth"; const targetPath = path.join(tempDir, "download.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }).catch(() => {}); }); // Staged file already holds the full planned snapshot; remote has grown since. const snapshot = Buffer.alloc(16 * 1024, 91); const grownRemote = Buffer.concat([snapshot, Buffer.alloc(4 * 1024, 92)]); await fs.promises.mkdir(path.dirname(stagedPath), { recursive: true }); await fs.promises.writeFile(stagedPath, snapshot); // Prefix fingerprint keeps the checkpoint (meta: fingerprints are cleared // and restart from zero before downloadFile runs). Matches the planned // snapshot size so append growth on the remote does not fail identity. const digest = crypto.createHash("sha256").update(snapshot).digest("hex"); const sourceFingerprint = `sha256:p${snapshot.length}:${digest}`; let bodyOpenAtOldEof = false; let openCalls = 0; let verifyRangeReads = 0; const sharedSftp = createFastSftp({ open(_remotePath, _flags, callback) { openCalls += 1; callback(null, Buffer.from("verify-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (position >= snapshot.length) { bodyOpenAtOldEof = true; throw new Error("must not READ past planned snapshot"); } verifyRangeReads += 1; const end = Math.min(position + length, snapshot.length); snapshot.subarray(position, end).copy(buffer, offset); setImmediate(() => callback(null, end - position)); }, close(_handle, callback) { callback(null); }, createReadStream(_remotePath, options = {}) { const start = Number.isFinite(options.start) ? options.start : 0; // Prefix verification may sample [0, snapshot); body open at EOF is the bug. if (start >= snapshot.length) bodyOpenAtOldEof = true; // Allow prefix hash reads for resume verification; reject transfer-body EOF opens. if (start >= snapshot.length) { throw new Error("must not open createReadStream at planned EOF"); } return Readable.from([Buffer.from(grownRemote.subarray(start, (options.end ?? start) + 1))]); }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: grownRemote.length, mtimeMs: 2_000, ctimeMs: 2_000, mtime: 2, ctime: 2, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/var/log/app.log", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: snapshot.length, resumable: true, checkpointBytes: snapshot.length, sourceFingerprint, }, ); assert.equal(result.error, undefined, result.error); assert.ok(verifyRangeReads > 0, "checkpoint-complete growth must use bounded prefix ranges"); assert.ok(openCalls > 0, "prefix verification may open a read handle at offset 0"); assert.equal(bodyOpenAtOldEof, false, "must not open transfer body at the old EOF"); const downloaded = await fs.promises.readFile(targetPath); assert.deepEqual(downloaded, snapshot); }); test("zero-byte snapshot is complete when remote has grown (no body open)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-empty-growth-")); const transferId = "download-empty-snapshot-growth"; const targetPath = path.join(tempDir, "empty.log"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }).catch(() => {}); }); // Preflight sees an empty file; later stats report append growth. The body // path must not open (unbounded read would pull the tail and fail). const grownRemote = Buffer.from("appended-after-empty-snapshot"); let remoteSize = 0; let bodyOpened = false; const sharedSftp = createFastSftp({ open() { bodyOpened = true; throw new Error("must not OPEN body for zero-byte snapshot"); }, read() { bodyOpened = true; throw new Error("must not READ body for zero-byte snapshot"); }, createReadStream() { bodyOpened = true; throw new Error("must not createReadStream for zero-byte snapshot"); }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: remoteSize, mtimeMs: remoteSize > 0 ? 3_000 : 1_000, ctimeMs: remoteSize > 0 ? 3_000 : 1_000, mtime: remoteSize > 0 ? 3 : 1, ctime: remoteSize > 0 ? 3 : 1, }); }, }; // Grow after the planned empty snapshot is fixed (async gap before body I/O). const growTimer = setTimeout(() => { remoteSize = grownRemote.length; }, 0); t.after(() => clearTimeout(growTimer)); transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/var/log/empty-then-grown.log", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 0, resumable: true, }, ); assert.equal(result.error, undefined, result.error); assert.equal(bodyOpened, false, "zero-byte planned snapshot must not open a source body"); const downloaded = await fs.promises.readFile(targetPath); assert.equal(downloaded.length, 0); }); test("omitted totalBytes discovers remote size and downloads non-empty body", async (t) => { // Explicit totalBytes:0 is an empty snapshot; omitting totalBytes must STAT // and READ the remote (external-editor temp download path, #2787). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-omit-total-")); const transferId = "download-omit-total-bytes"; const targetPath = path.join(tempDir, "report.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }).catch(() => {}); }); const expected = Buffer.from("external-editor-contents"); let statCalls = 0; let bodyOpened = false; const { sftp: sharedSftp } = createPipelinedDownloadSftp(expected, { open(_remotePath, flags, callback) { bodyOpened = true; if (typeof flags === "function") { flags(null, Buffer.from("read-handle")); return; } callback(null, Buffer.from("read-handle")); }, }); const client = { sftp: sharedSftp, stat() { statCalls += 1; return Promise.resolve({ size: expected.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/remote/report.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", // intentionally omit totalBytes }, ); assert.equal(result.error, undefined, result.error); assert.ok(statCalls >= 1, "omitted totalBytes must discover remote size via STAT"); assert.equal(bodyOpened, true, "omitted totalBytes must open remote body for non-empty file"); assert.deepEqual(await fs.promises.readFile(targetPath), expected); }); test("SFTP uploads fail when remote size does not match local size", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-size-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "archive.zip"); await fs.promises.writeFile(localPath, Buffer.alloc(1024 * 1024)); let deletedRemotePath = null; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({}), stat() { // Simulate a truncated remote file after a "successful" fastPut. return Promise.resolve({ size: 512 * 1024 }); }, delete(remotePath) { deletedRemotePath = remotePath; return Promise.resolve(); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const result = await transferBridge.startTransfer( { sender }, { transferId: "upload-truncated", sourcePath: localPath, targetPath: "/tmp/archive.zip", sourceType: "local", targetType: "sftp", targetSftpId: "target", }, ); assert.match(result.error || "", /Upload size mismatch/); assert.match(String(deletedRemotePath), /\.netcatty-upload-.*archive\.zip\.part$/); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error")); }); test("uploads prefer concurrent shared channel over serial WriteStream", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-concurrent-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 91); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); let maxInFlight = 0; let activeWrites = 0; let createWriteStreamCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "w"); callback(null, Buffer.from("shared-handle")); }, write(_handle, _buffer, _offset, length, _position, callback) { activeWrites += 1; maxInFlight = Math.max(maxInFlight, activeWrites); setImmediate(() => { activeWrites -= 1; callback(null); }); }, close(_handle, callback) { callback(null); }, createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not be used when concurrent works"); }, }); const client = { // Force the isolated-channel path to be unavailable (sudo / no secondary channel). __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-shared-concurrent", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined); assert.equal(createWriteStreamCalls, 0); // Pipelined fanout must be multi-WRITE (not serial 1-in-flight). assert.ok( maxInFlight >= 2, `expected pipelined concurrency >= 2, got ${maxInFlight}`, ); assert.ok( maxInFlight <= UPLOAD_TRANSFER_CONCURRENCY, `expected concurrency <= ${UPLOAD_TRANSFER_CONCURRENCY}, got ${maxInFlight}`, ); }); test("cancel during stalled shared upload OPEN drains before remote cleanup", async (t) => { // Codex P2 on a7f96c94: cancel rejected shared write OPEN immediately, so // runRemoteUploadTransaction cleaned up the stage before a late "w" OPEN // recreated it — leaving an orphan .part (or truncating an in-place target). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-drain-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 41)); const remoteFiles = new Map(); const eventLog = []; let releaseOpen = null; let endCalls = 0; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run after cancel during OPEN"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); } return Promise.resolve({ size: remoteFiles.get(key).length }); }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-drain-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared write OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId }); // Must still be waiting for OPEN drain — immediate settle would be the bug. let settledEarly = false; await Promise.race([ running.then(() => { settledEarly = true; }), new Promise((resolve) => setTimeout(resolve, 100)), ]); assert.equal(settledEarly, false, "shared write OPEN cancel must drain before settle"); releaseOpen?.(); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout(() => reject(new Error("transfer did not settle after drained shared write OPEN")), 3000); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); const closeIdx = eventLog.indexOf("close"); const deleteIdx = eventLog.findIndex((entry) => entry.startsWith("delete:")); assert.ok(closeIdx >= 0, `expected CLOSE before cleanup, log=${eventLog.join(",")}`); assert.ok(deleteIdx >= 0, `expected stage delete after CLOSE, log=${eventLog.join(",")}`); assert.ok(closeIdx < deleteIdx, `CLOSE must precede cleanup delete, log=${eventLog.join(",")}`); assert.equal( [...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false, `expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`, ); }); test("shared upload OPEN drain force-completes when cancel has no OPEN callback", async (t) => { // Codex P2 on cd57d960: cancel settle without force-complete left // sharedWriteOpenDrain pending forever when OPEN never arrived and no // channel error fired, hanging the transfer and SFTP lease. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-cancel-no-callback-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 51)); let endCalls = 0; let openCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, _callback) { assert.equal(flags, "w"); openCalls += 1; // Never invoke the OPEN callback (stalled shared channel, no error). }, write() { throw new Error("WRITE must not run after cancel during OPEN"); }, close() { throw new Error("CLOSE must not run without an OPEN handle"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); }, rename() { return Promise.resolve(); }, async delete() {}, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-cancel-no-callback"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-cancel-hang.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => openCalls >= 1, 2000); assert.ok(ready, "expected shared write OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting shared write OPEN drain after cancel")), 5500, ); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("shared upload cancel settle does not hang when best-effort unlink never callbacks", async (t) => { // Codex P2 on 3f0f4608: cancel + OPEN "w" waited for unlink before finishCancel. // A dead shared channel that never invokes unlink left the transfer/lease active. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-unlink-hang-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61)); let openCalls = 0; let unlinkCalls = 0; let endCalls = 0; let openCallback = null; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "w"); openCalls += 1; openCallback = callback; }, unlink(_remotePath, _callback) { unlinkCalls += 1; // Never invoke unlink callback (stalled shared channel). }, write() { throw new Error("WRITE must not run after cancel during OPEN"); }, close(_handle, callback) { callback?.(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); }, rename() { return Promise.resolve(); }, async delete() {}, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-cancel-unlink-hang"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-cancel-unlink-hang.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => openCalls >= 1 && openCallback, 2000); assert.ok(ready, "expected shared write OPEN to stall with callback held"); await transferBridge.cancelTransfer(null, { transferId }); // OPEN returns after cancel — triggers close + best-effort unlink path. openCallback(null, Buffer.from("handle:hang-unlink")); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting best-effort unlink after cancel")), 5500, ); }), ]); assert.match(result.error || "", /cancel/i); assert.ok(unlinkCalls >= 1, "expected best-effort unlink after cancel OPEN"); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); }); test("shared upload OPEN drain survives cancel settle timeout", async (t) => { // Codex P2 on af9cef2e / 0cda4a39 / cd57d960: cancel settle + drain // force-complete must not hang, and a late truncating OPEN on a generated // stage after that must close + unlink so stage cleanup cannot leave a // recreate orphan. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-drain-timeout-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 43)); const remoteFiles = new Map(); const eventLog = []; let releaseOpen = null; let endCalls = 0; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run after cancel during OPEN"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); } return Promise.resolve({ size: remoteFiles.get(key).length }); }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-drain-timeout"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-timeout.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared write OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId }); // Past cancel settle (2s) + drain force-complete (2s): transfer must settle // without an OPEN callback so lease/admission cannot hang. const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout(() => reject(new Error("transfer hung after cancel OPEN drain force-complete")), 5500); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); // Late OPEN after force-complete — must close + unlink, no orphan stage. releaseOpen?.(); const lateCleanup = await waitUntil( () => eventLog.includes("close") && eventLog.some((entry) => entry.startsWith("unlink:")), 2000, ); assert.ok(lateCleanup, `expected late OPEN close+unlink, log=${eventLog.join(",")}`); assert.equal( [...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false, `expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`, ); }); test("in-place shared upload cancel does not unlink final target after late OPEN", async (t) => { // Codex P1 on a9f748c8: cancel+truncating "w" unlinked filePath unconditionally, // so late OPEN on an in-place/symlink destination could delete the final target. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-inplace-open-cancel-nounlink-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 53)); const targetPath = "/tmp/inplace-link.bin"; const existingPayload = Buffer.from("keep-me"); const remoteFiles = new Map([[targetPath, Buffer.from(existingPayload)]]); const eventLog = []; let releaseOpen = null; let endCalls = 0; const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }, open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); assert.equal(key, targetPath, "in-place upload must OPEN the final target"); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run after cancel during OPEN"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-inplace-open-cancel-nounlink"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: false, skipAdmission: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared in-place write OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout(() => reject(new Error("transfer hung after in-place cancel OPEN drain force-complete")), 5500); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); releaseOpen?.(); const lateClose = await waitUntil(() => eventLog.includes("close"), 2000); assert.ok(lateClose, `expected late OPEN close, log=${eventLog.join(",")}`); assert.equal( eventLog.some((entry) => entry.startsWith("unlink:")), false, `in-place cancel must not unlink final target, log=${eventLog.join(",")}`, ); assert.equal( eventLog.some((entry) => entry === `delete:${targetPath}`), false, `in-place cancel must not delete final target, log=${eventLog.join(",")}`, ); assert.ok(remoteFiles.has(targetPath), "final in-place target must still exist after cancel"); }); test("shared upload OPEN drain survives channel error before OPEN callback", async (t) => { // Codex P2 on 532cf6cc: channel error completed sharedWriteOpenDrain before the // OPEN callback, so cleanup could race a late truncating OPEN. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-drain-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 47)); const remoteFiles = new Map(); const eventLog = []; let releaseOpen = null; let endCalls = 0; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); } return Promise.resolve({ size: remoteFiles.get(key).length }); }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-channel-error-drain"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-channel-error.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died during upload OPEN")); // Channel error settles the OPEN wait, but cleanup must wait for drain. await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(eventLog.some((entry) => entry.startsWith("delete:")), false, "cleanup must not run before late OPEN drain after channel error"); releaseOpen?.(); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout(() => reject(new Error("transfer did not settle after channel error + late OPEN")), 5000); }), ]); assert.ok(result.error, "expected transfer to fail after channel error"); assert.match(result.error, /shared SFTP channel died during upload OPEN/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.ok(eventLog.includes("close"), `expected CLOSE after late OPEN, log=${eventLog.join(",")}`); // Resumable uploads preserve the stage on error (not cancel), so delete may // not run; the invariant is drain completed (CLOSE) before the failure path // returned, with no racing truncating OPEN after settle. assert.ok( eventLog.indexOf("close") >= 0 && eventLog.findIndex((entry) => entry.startsWith("open-created:")) >= 0 && eventLog.indexOf("close") > eventLog.findIndex((entry) => entry.startsWith("open-created:")), `late OPEN must be closed before failure returns, log=${eventLog.join(",")}`, ); }); test("shared upload OPEN drain force-completes when channel error has no OPEN callback", async (t) => { // Codex P2 on 90494c0c: channel error rejected OPEN but left sharedWriteOpenDrain // pending forever when the OPEN callback never arrived, hanging the transfer // and SFTP lease on a dead shared channel. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-no-callback-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 49)); let endCalls = 0; let openCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, _callback) { assert.equal(flags, "w"); openCalls += 1; // Never invoke the OPEN callback (dead channel after error). }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, close() { throw new Error("CLOSE must not run without an OPEN handle"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { const error = new Error("ENOENT"); error.code = 2; return Promise.reject(error); }, rename() { return Promise.resolve(); }, async delete() {}, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-channel-error-no-callback"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-channel-error-hang.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const ready = await waitUntil(() => openCalls >= 1, 2000); assert.ok(ready, "expected shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died with no OPEN callback")); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting shared write OPEN drain after channel error")), 5000, ); }), ]); assert.ok(result.error, "expected transfer to fail after channel error"); assert.match(result.error, /shared SFTP channel died with no OPEN callback/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("fastPut fallback cancel settles while prior isolated write OPEN gate is unresolved", async (t) => { // #2755 / Codex P2 on 667e9115: non-resumable concurrent-isolated OPEN emits a // channel error without invoking its callback, so pendingWriteOpenPathGate // stays unresolved by design. uploadFile then awaits that gate before // fastPut; uploadFileConcurrent already cleared transfer.abort, so Cancel // must still settle the transfer (not hang on the unconditional await). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-gate-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 61); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-fastput-gate-cancel.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstIsolated = null; let fastPutCalls = 0; let reopenedEnded = 0; const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, }); const client = { sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ open(_remotePath, flags, _cb) { assert.equal(flags, "w"); firstOpenStarted = true; // Never invoke the OPEN callback (dead isolated channel). }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, close() { throw new Error("CLOSE must not run without an OPEN handle"); }, fastPut() { throw new Error("first isolated channel must not fastPut"); }, end() {}, }); callback(null, firstIsolated); return; } // Reopened channel for fastPut fallback — would succeed if reached. callback(null, createFastSftp({ fastPut(_local, _remote, _opts, cb) { fastPutCalls += 1; cb(null); }, end() { reopenedEnded += 1; }, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-fastput-gate-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected concurrent-isolated write OPEN to stall"); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); // Wait until uploadFile has fallen through concurrent and reopened for fastPut // (second isolated channel), which is when it awaits pendingWriteOpenPathGate. const reopened = await waitUntil(() => isolatedChannelCount >= 2, 2000); assert.ok(reopened, "expected fastPut isolated channel reopen while OPEN gate pending"); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting pendingWriteOpenPathGate before fastPut")), 1500, ); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(fastPutCalls, 0, "cancel must settle before fastPut runs on unresolved gate"); assert.equal(reopenedEnded, 1, "cancel during gate wait must end the reopened isolated channel"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true); }); test("fastPut fallback fails closed when prior isolated write OPEN gate never settles", async (t) => { // #2755 companion: without cancel, a dead isolated OPEN that never callbacks // must not pin the transfer forever on pendingWriteOpenPathGate, and must not // fall through to another writer that races the still-pending truncate. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-gate-timeout-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 62); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-fastput-gate-timeout.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstIsolated = null; let fastPutCalls = 0; let reopenedEnded = 0; let sharedWriteOpens = 0; const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(_remotePath, flags, callback) { sharedWriteOpens += 1; callback(null, Buffer.from(`shared-handle:${flags}`)); }, write(_handle, _buffer, _offset, length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, createWriteStream() { throw new Error("stream fallback must not run after noTransferFallback gate timeout"); }, }); const client = { sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ open(_remotePath, flags) { assert.equal(flags, "w"); firstOpenStarted = true; }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, end() {}, }); callback(null, firstIsolated); return; } callback(null, createFastSftp({ fastPut(_local, _remote, _opts, cb) { fastPutCalls += 1; cb(null); }, end() { reopenedEnded += 1; }, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-fastput-gate-timeout"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected concurrent-isolated write OPEN to stall"); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting pendingWriteOpenPathGate before fastPut")), 5000, ); }), ]); assert.match(result.error || "", /Timed out waiting for prior write OPEN to settle before fastPut/i); assert.equal(fastPutCalls, 0, "must not fastPut while prior OPEN gate is unresolved"); assert.equal(sharedWriteOpens, 0, "must not fall through to shared write after gate timeout"); assert.equal(reopenedEnded, 1, "gate timeout must end the reopened isolated channel"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("later same-path upload fails promptly after prior write OPEN gate is poisoned", async (t) => { // Codex P2 on dca41093: after the fail-closed fastPut gate timeout, the dead // OPEN's truncatingSharedWriteOpenGates entry must not leave a later in-place // upload blocked forever in pathGate.waitForPrior. Use a symlink destination // so both attempts OPEN the same final path (allowInPlaceFallback). Subsequent // waiters must fail promptly (noTransferFallback) while the barrier stays // fail-closed — they must not hang and must not issue another truncating OPEN. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 63); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-inplace-gate-fail-closed.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstOpenPath = null; let firstIsolated = null; let laterWriteOpens = 0; const symlinkLstat = (remotePath, callback) => { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }; const sharedSftp = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, callback) { laterWriteOpens += 1; callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, createWriteStream() { throw new Error("stream fallback must not run after fail-closed path gate"); }, }); const client = { sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags) { assert.equal(flags, "w"); assert.equal(String(remotePath), targetPath, "first attempt must OPEN in-place final path"); firstOpenPath = String(remotePath); firstOpenStarted = true; }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, end() {}, }); callback(null, firstIsolated); return; } // Later isolated channels: OPEN must not be reached while the path gate // is fail-closed; count attempts if the barrier is incorrectly released. callback(null, createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, cb) { laterWriteOpens += 1; cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, cb) { cb(null); }, close(_handle, cb) { cb(null); }, fastPut(_local, _remote, _opts, cb) { cb(null); }, end() {}, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstSender = createSender(); const firstRunning = transferBridge.startTransfer( { sender: firstSender }, { transferId: "upload-inplace-gate-fail-closed-first", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected concurrent-isolated in-place write OPEN to stall"); assert.equal(firstOpenPath, targetPath); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout( () => reject(new Error("first transfer hung awaiting pendingWriteOpenPathGate before fastPut")), 5000, ); }), ]); assert.match( firstResult.error || "", /isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i, ); // Later same-path in-place upload must fail promptly — not hang on the // unresolved truncatingSharedWriteOpenGates entry (Codex P2). const laterSender = createSender(); const laterStartedAt = Date.now(); const laterRunning = transferBridge.startTransfer( { sender: laterSender }, { transferId: "upload-inplace-gate-fail-closed-later", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const laterResult = await Promise.race([ laterRunning, new Promise((_, reject) => { setTimeout( () => reject(new Error("later same-path upload hung on unresolved write OPEN path gate")), 1500, ); }), ]); assert.ok( Date.now() - laterStartedAt < 1500, "later upload must settle promptly after fail-closed path gate", ); assert.ok(laterResult.error, "expected later upload to fail closed on unresolved path gate"); assert.match( laterResult.error, /Timed out waiting for prior write OPEN|prior write OPEN never settled|path gate is fail-closed|write OPEN path gate|In-place write OPEN poison/i, ); assert.equal( laterWriteOpens, 0, "fail-closed path gate must not allow a later truncating write OPEN", ); assert.equal(laterSender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("path gate poison survives a same-path waiter queued during fastPut timeout", async (t) => { // Codex P1 on 0292802c: if another upload replaces the map entry during the // 2s fastPut gate wait, fail() must still leave a fail-closed barrier so a // third upload cannot OPEN while the original truncating OPEN may land. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-poison-successor-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 64); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-inplace-gate-poison-successor.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstIsolated = null; let thirdWriteOpens = 0; const symlinkLstat = (remotePath, callback) => { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }; const client = { sftp: createFastSftp({ lstat: symlinkLstat, open() { throw new Error("shared OPEN must not run while path gate is fail-closed"); }, createWriteStream() { throw new Error("stream fallback must not run after fail-closed path gate"); }, }), async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags) { assert.equal(flags, "w"); assert.equal(String(remotePath), targetPath); firstOpenStarted = true; }, end() {}, }); callback(null, firstIsolated); return; } callback(null, createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, cb) { thirdWriteOpens += 1; cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, cb) { cb(null); }, close(_handle, cb) { cb(null); }, fastPut(_local, _remote, _opts, cb) { cb(null); }, end() {}, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-successor-first", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected first in-place write OPEN to stall"); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); // In-place OPEN poison is terminal immediately (no fastPut wait). Queue a // second same-path upload while the first is settling so fail() must still // leave a fail-closed barrier for a third upload. const secondRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-successor-second", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000); }), ]); assert.match( firstResult.error || "", /isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i, ); const secondResult = await Promise.race([ secondRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("second transfer hung after prior gate poison")), 1500); }), ]); assert.ok(secondResult.error, "expected second waiter to fail closed after poison"); assert.match( secondResult.error, /prior write OPEN never settled|path gate is fail-closed|In-place write OPEN poison/i, ); const thirdStartedAt = Date.now(); const thirdRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-successor-third", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const thirdResult = await Promise.race([ thirdRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("third upload hung or raced after successor release")), 1500); }), ]); assert.ok(Date.now() - thirdStartedAt < 1500, "third upload must settle promptly"); assert.ok(thirdResult.error, "expected third upload to fail closed"); assert.match(thirdResult.error, /prior write OPEN never settled|path gate is fail-closed/i); assert.equal(thirdWriteOpens, 0, "poisoned barrier must survive successor waiter release"); }); test("path gate poison clears after late original OPEN settles past successor", async (t) => { // Codex P2 on 64450bfd: after fail() poisons a queued successor, the OPEN // owner's late callback must still be able to release the barrier. Successor // fail propagation must not steal the map slot, or every later upload stays // fail-closed forever even though the dangerous OPEN has settled. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-poison-clear-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 66); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-inplace-gate-poison-clear.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let releaseFirstOpen = null; let firstIsolated = null; let postClearWriteOpens = 0; const symlinkLstat = (remotePath, callback) => { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }; const client = { sftp: createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, callback) { postClearWriteOpens += 1; callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, createWriteStream() { throw new Error("stream fallback must not run in this test"); }, }), async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, openCb) { assert.equal(flags, "w"); assert.equal(String(remotePath), targetPath); firstOpenStarted = true; releaseFirstOpen = () => { openCb(null, Buffer.from(`late-handle:${remotePath}`)); }; }, close(_handle, cb) { cb(null); }, unlink(_remotePath, cb) { cb(null); }, end() {}, }); callback(null, firstIsolated); return; } callback(null, createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, cb) { postClearWriteOpens += 1; remoteFiles.set(String(remotePath), Buffer.from(payload)); cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, cb) { cb(null); }, close(_handle, cb) { cb(null); }, fastPut(local, remote, _opts, cb) { postClearWriteOpens += 1; remoteFiles.set(String(remote), Buffer.from(payload)); cb(null); }, end() {}, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-clear-first", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected first in-place write OPEN to stall"); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); const secondRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-clear-second", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000); }), ]); assert.match( firstResult.error || "", /isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i, ); await Promise.race([ secondRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("second transfer hung after prior gate poison")), 1500); }), ]); // Late OPEN from the original attempt settles and must clear the owner's // poisoned barrier even though a successor was poisoned during the wait. assert.equal(typeof releaseFirstOpen, "function", "expected late OPEN callback to be capturable"); releaseFirstOpen(); await new Promise((resolve) => setTimeout(resolve, 100)); const thirdRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-poison-clear-third", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const thirdResult = await Promise.race([ thirdRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("third upload hung after late OPEN should have cleared poison")), 5000); }), ]); assert.notEqual( thirdResult.error && /prior write OPEN never settled|path gate is fail-closed/i.test(thirdResult.error), true, `third upload must not stay permanently fail-closed after late OPEN release, error=${thirdResult.error}`, ); assert.ok( postClearWriteOpens > 0 || (thirdResult.transferId && thirdResult.error == null && thirdResult.cancelled !== true) || thirdResult.cancelled === true || thirdResult.error, `expected third upload to settle after barrier clear, result=${JSON.stringify(thirdResult)}`, ); }); test("later same-path upload fails promptly after cancel during unresolved OPEN gate wait", async (t) => { // Codex P2 on 0292802c: cancel during the fastPut pending-gate wait must // poison the shared path gate, same as the idle timeout path. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-cancel-poison-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 65); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-inplace-gate-cancel-poison.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstIsolated = null; let laterWriteOpens = 0; const symlinkLstat = (remotePath, callback) => { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }; const client = { sftp: createFastSftp({ lstat: symlinkLstat, open() { laterWriteOpens += 1; throw new Error("shared OPEN must not run while path gate is fail-closed"); }, createWriteStream() { throw new Error("stream fallback must not run after fail-closed path gate"); }, }), async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags) { assert.equal(flags, "w"); assert.equal(String(remotePath), targetPath); firstOpenStarted = true; }, end() {}, }); callback(null, firstIsolated); return; } callback(null, createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, cb) { laterWriteOpens += 1; cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, cb) { cb(null); }, close(_handle, cb) { cb(null); }, fastPut(_local, _remote, _opts, cb) { cb(null); }, end() {}, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstTransferId = "upload-inplace-gate-cancel-poison-first"; const firstRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: firstTransferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected first in-place write OPEN to stall"); // Cancel while the in-place OPEN is still unsettled. After the settle timeout // the path gate is poisoned so later same-path uploads fail promptly. await transferBridge.cancelTransfer(null, { transferId: firstTransferId }); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("first transfer hung on cancel during OPEN")), 5000); }), ]); assert.match(firstResult.error || "", /cancel/i); const laterStartedAt = Date.now(); const laterRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-cancel-poison-later", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const laterResult = await Promise.race([ laterRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("later upload hung after cancel left path gate unresolved")), 1500); }), ]); assert.ok(Date.now() - laterStartedAt < 1500, "later upload must settle promptly after cancel poison"); assert.ok(laterResult.error, "expected later upload to fail closed"); assert.match(laterResult.error, /prior write OPEN never settled|path gate is fail-closed/i); assert.equal(laterWriteOpens, 0, "cancel poison must not allow a later truncating write OPEN"); }); test("path gate poison clears after owning isolated transport ends", async (t) => { // Codex P2 on 713719c2: a dead OPEN that never callbacks leaves a host-keyed // poison. Once the owning isolated channel has ended and a short grace // passes, clear the barrier so reconnect / later uploads are not // permanently fail-closed until process restart. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-transport-clear-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 67); const localPath = path.join(tempDir, "upload.bin"); const targetPath = "/tmp/upload-inplace-gate-transport-clear.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("old")]]); await fs.promises.writeFile(localPath, payload); let isolatedChannelCount = 0; let firstOpenStarted = false; let firstIsolated = null; let postClearWriteOpens = 0; const symlinkLstat = (remotePath, callback) => { const key = String(remotePath); if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }; const client = { sftp: createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, callback) { postClearWriteOpens += 1; remoteFiles.set(String(remotePath), Buffer.from(payload)); callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, callback) { callback(null); }, close(_handle, callback) { callback(null); }, createWriteStream() { throw new Error("stream fallback must not run in this test"); }, }), async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, client: { sftp(callback) { isolatedChannelCount += 1; if (isolatedChannelCount === 1) { firstIsolated = createFastSftp({ lstat: symlinkLstat, open(remotePath, flags) { assert.equal(flags, "w"); assert.equal(String(remotePath), targetPath); firstOpenStarted = true; }, end() { this.emit("end"); this.emit("close"); }, }); callback(null, firstIsolated); return; } callback(null, createFastSftp({ lstat: symlinkLstat, open(remotePath, flags, cb) { postClearWriteOpens += 1; remoteFiles.set(String(remotePath), Buffer.from(payload)); cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`)); }, write(_handle, _buffer, _offset, length, _position, cb) { cb(null); }, close(_handle, cb) { cb(null); }, fastPut(local, remote, _opts, cb) { postClearWriteOpens += 1; remoteFiles.set(String(remote), Buffer.from(payload)); cb(null); }, end() {}, })); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-transport-clear-first", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const openReady = await waitUntil(() => firstOpenStarted, 2000); assert.ok(openReady, "expected first in-place write OPEN to stall"); firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN")); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000); }), ]); assert.match( firstResult.error || "", /isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i, ); // Immediately after poison the barrier must still fail closed. const midRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-transport-clear-mid", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const midResult = await Promise.race([ midRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("mid upload hung on poisoned gate")), 1500); }), ]); assert.match(midResult.error || "", /prior write OPEN never settled|path gate is fail-closed/i); // After transport-gone grace, the host-keyed poison must clear. await new Promise((resolve) => setTimeout(resolve, 2100)); const laterRunning = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-inplace-gate-transport-clear-later", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }, ); const laterResult = await Promise.race([ laterRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("later upload hung after transport-gone poison clear")), 5000); }), ]); assert.notEqual( laterResult.error && /prior write OPEN never settled|path gate is fail-closed/i.test(laterResult.error), true, `later upload must not stay permanently fail-closed after transport end, error=${laterResult.error}`, ); assert.ok( postClearWriteOpens > 0 || (laterResult.transferId && laterResult.error == null && laterResult.cancelled !== true) || laterResult.cancelled === true || laterResult.error, `expected later upload to settle after transport-gone clear, result=${JSON.stringify(laterResult)}`, ); }); test("shared upload OPEN drain unlinks late staged OPEN after channel-error force-complete", async (t) => { // Codex P2 on bd42c51c: late truncating OPEN after channel-error drain // force-complete is not a cancel (`transfer.cancelled` false). Non-resumable // staged uploads already cleaned the stage after the forced drain; the late // "w" OPEN must still unlink the recreated `.netcatty-upload-*` orphan. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-late-unlink-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 55)); const remoteFiles = new Map(); const eventLog = []; let releaseOpen = null; let endCalls = 0; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run after channel error during OPEN"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "upload-shared-open-channel-error-late-unlink"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: localPath, targetPath: "/tmp/upload-channel-error-late-unlink.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, // Non-resumable → cleanupStage runs after failure (no preserve-on-error). resumable: false, skipAdmission: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died before OPEN callback")); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung awaiting shared write OPEN drain after channel error")), 5000, ); }), ]); assert.ok(result.error, "expected transfer to fail after channel error"); assert.match(result.error, /shared SFTP channel died before OPEN callback/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); // Late OPEN after drain force-complete — must close + unlink generated stage. releaseOpen?.(); const lateCleanup = await waitUntil( () => eventLog.includes("close") && eventLog.some((entry) => entry.startsWith("unlink:")), 2000, ); assert.ok(lateCleanup, `expected late OPEN close+unlink after channel error, log=${eventLog.join(",")}`); assert.equal( [...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false, `expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`, ); }); test("late shared OPEN unlink still cleans non-resumable stage under same-id retry", async (t) => { // Codex P2 on 7c446c7: retry ownership must not suppress unlink for every // generatedStagePath. Non-resumable uploads use a unique // `.netcatty-upload-*` path per attempt; a stale late OPEN on attempt 1's // path must still unlink so the orphan is not left behind when a same-id // retry already owns activeTransfers. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-nonresume-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 59)); const targetPath = "/tmp/upload-late-unlink-nonresume.bin"; const transferId = "upload-shared-open-late-unlink-nonresume"; const remoteFiles = new Map(); const eventLog = []; /** @type {null | (() => void)} */ let releaseFirstOpen = null; let openGeneration = 0; let endCalls = 0; /** @type {null | (() => void)} */ let releaseRetryWrites = null; const retryWritesReleased = new Promise((resolve) => { releaseRetryWrites = resolve; }); /** @type {string | null} */ let firstStagePath = null; /** @type {string | null} */ let retryStagePath = null; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); openGeneration += 1; const generation = openGeneration; if (generation === 1) { firstStagePath = key; assert.match(key, /\.netcatty-upload-.*\.part$/, "first attempt must OPEN unique non-resumable stage"); releaseFirstOpen = () => { if (!remoteFiles.has(key)) { remoteFiles.set(key, Buffer.alloc(0)); } eventLog.push(`open-created-1:${key}`); callback(null, Buffer.from(`handle-1:${key}`)); }; return; } retryStagePath = key; assert.match(key, /\.netcatty-upload-.*\.part$/, "retry must OPEN a fresh non-resumable stage"); assert.notEqual(key, firstStagePath, "non-resumable retry must not reuse first stage path"); remoteFiles.set(key, Buffer.from("retry-stage")); eventLog.push(`open-created-${generation}:${key}`); callback(null, Buffer.from(`handle-${generation}:${key}`)); }, write(handle, buffer, offset, length, position, callback) { void retryWritesReleased.then(() => { const key = String(handle).replace(/^handle-\d+:/, ""); const current = remoteFiles.get(key) || Buffer.alloc(0); const end = position + length; const next = Buffer.alloc(Math.max(current.length, end)); current.copy(next); buffer.copy(next, position, offset, offset + length); remoteFiles.set(key, next); eventLog.push(`write:${key}:${length}@${position}`); setImmediate(() => callback(null)); }); }, close(handle, callback) { eventLog.push(`close:${String(handle)}`); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstSender = createSender(); const firstRunning = transferBridge.startTransfer( { sender: firstSender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: false, skipAdmission: true, }, ); const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000); assert.ok(firstOpenReady, "expected first shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback")); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout( () => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")), 5000, ); }), ]); assert.ok(firstResult.error, "expected first transfer to fail after channel error"); assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); // Same transfer id retry (non-resumable) owns a different stage path before // the stale OPEN callback from attempt 1 arrives. transferBridge.clearPendingCancel(transferId); const retrySender = createSender(); const retryRunning = transferBridge.startTransfer( { sender: retrySender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: false, skipAdmission: true, }, ); const retryOpenReady = await waitUntil( () => eventLog.some((entry) => entry.startsWith("open-created-2:")), 3000, ); assert.ok(retryOpenReady, `expected retry OPEN, log=${eventLog.join(",")}`); assert.ok(retryStagePath, "retry must have opened a stage"); assert.ok( remoteFiles.has(retryStagePath), "retry must own its unique stage before stale late OPEN", ); const retryStageBeforeLateOpen = Buffer.from(remoteFiles.get(retryStagePath)); // Stale late OPEN from attempt 1: must close + unlink attempt-1 stage only. releaseFirstOpen?.(); const lateCleanup = await waitUntil( () => eventLog.some((entry) => entry.startsWith("close:handle-1:")) && eventLog.some((entry) => entry === `unlink:${firstStagePath}`), 2000, ); assert.ok( lateCleanup, `expected late close+unlink of first non-resumable stage, log=${eventLog.join(",")}`, ); assert.equal( eventLog.some((entry) => entry === `unlink:${retryStagePath}`), false, `stale late OPEN must not unlink retry stage, log=${eventLog.join(",")}`, ); assert.ok( remoteFiles.has(retryStagePath), "retry stage must survive stale late OPEN on a different path", ); assert.ok( remoteFiles.get(retryStagePath).equals(retryStageBeforeLateOpen), "retry stage bytes must not be removed by stale late OPEN unlink", ); assert.equal( remoteFiles.has(firstStagePath), false, "first attempt non-resumable stage must be unlinked (no orphan)", ); releaseRetryWrites?.(); await transferBridge.cancelTransfer(null, { transferId }); const retryResult = await Promise.race([ retryRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("retry transfer hung after cancel")), 5000); }), ]); assert.ok( retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")), `expected retry cancel settle, result=${JSON.stringify(retryResult)}`, ); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); }); test("late shared OPEN unlink skips same-id retry resume stage", async (t) => { // Codex P2 on d19ecb88 + P1 on 42a27ef7: // - After drain force-complete, a late truncating "w" must not unlink a // same-id retry's deterministic resume stage. // - Path gate serializes truncating OPENs on that path; if force-complete // already released the gate and the retry accepted its own OPEN, a late // stale OPEN that re-truncates must invalidate the retry rather than allow // a sparse promote. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-retry-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 57)); const targetPath = "/tmp/upload-late-unlink-retry.bin"; const transferId = "upload-shared-open-late-unlink-retry"; const deterministicStagePath = `/tmp/.upload-late-unlink-retry.bin.netcatty-${transferId}.part`; const remoteFiles = new Map(); const eventLog = []; /** @type {null | (() => void)} */ let releaseFirstOpen = null; let openGeneration = 0; let endCalls = 0; /** @type {null | (() => void)} */ let releaseRetryWrites = null; const retryWritesReleased = new Promise((resolve) => { releaseRetryWrites = resolve; }); const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); openGeneration += 1; const generation = openGeneration; if (generation === 1) { assert.equal(key, deterministicStagePath, "first attempt must OPEN deterministic resume stage"); releaseFirstOpen = () => { // Model a late server-side truncating OPEN: wipe whatever is at the // path when the stale OPEN finally applies. remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created-1:${key}`); callback(null, Buffer.from(`handle-1:${key}`)); }; return; } remoteFiles.set(key, Buffer.from("retry-stage")); eventLog.push(`open-created-${generation}:${key}`); callback(null, Buffer.from(`handle-${generation}:${key}`)); }, write(handle, buffer, offset, length, position, callback) { // Hold retry WRITEs so the stale OPEN can land mid-attempt. void retryWritesReleased.then(() => { const key = String(handle).replace(/^handle-\d+:/, ""); const current = remoteFiles.get(key) || Buffer.alloc(0); const end = position + length; const next = Buffer.alloc(Math.max(current.length, end)); current.copy(next); buffer.copy(next, position, offset, offset + length); remoteFiles.set(key, next); eventLog.push(`write:${key}:${length}@${position}`); setImmediate(() => callback(null)); }); }, close(handle, callback) { eventLog.push(`close:${String(handle)}`); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstSender = createSender(); const firstRunning = transferBridge.startTransfer( { sender: firstSender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000); assert.ok(firstOpenReady, "expected first shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback")); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout( () => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")), 5000, ); }), ]); assert.ok(firstResult.error, "expected first transfer to fail after channel error"); assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); // Same-id retry reuses the deterministic stage. Drain force-complete does // not release the path gate: the retry must wait until the stale OPEN // callback settles (Codex P1 — no hard-cap race for different transferIds). transferBridge.clearPendingCancel(transferId); const retrySender = createSender(); const retryRunning = transferBridge.startTransfer( { sender: retrySender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); await new Promise((resolve) => setTimeout(resolve, 500)); assert.equal( eventLog.some((entry) => entry.startsWith("open-created-2:")), false, `retry must not OPEN while prior gate is held, log=${eventLog.join(",")}`, ); // Settling the first OPEN frees the gate; then the retry may proceed. releaseFirstOpen?.(); const lateClose = await waitUntil( () => eventLog.some((entry) => entry.startsWith("close:handle-1:")), 2000, ); assert.ok(lateClose, `expected close of first OPEN handle, log=${eventLog.join(",")}`); const retryOpenReady = await waitUntil( () => eventLog.some((entry) => entry.startsWith("open-created-2:")), 5000, ); assert.ok(retryOpenReady, `expected retry OPEN after prior gate release, log=${eventLog.join(",")}`); releaseRetryWrites?.(); const retryResult = await Promise.race([ retryRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("retry transfer hung after prior OPEN settled")), 8000); }), ]); // Normal success is { transferId, totalBytes }; cancel/error also settle. assert.ok( retryResult && ( (retryResult.transferId && retryResult.error == null && retryResult.cancelled !== true) || retryResult.cancelled === true || retryResult.error ), `expected retry to settle after gate release, result=${JSON.stringify(retryResult)}`, ); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); }); test("truncating shared OPEN path gate serializes same-path retry until prior OPEN settles", async (t) => { // Codex P1 on 42a27ef7: while attempt-1's truncating OPEN is still pending // (no force-complete yet), a same-id retry must not issue OPEN "w" on the // deterministic stage — wait for the prior gate. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-path-gate-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61)); const targetPath = "/tmp/upload-path-gate.bin"; const transferId = "upload-shared-open-path-gate"; const deterministicStagePath = `/tmp/.upload-path-gate.bin.netcatty-${transferId}.part`; const remoteFiles = new Map(); const eventLog = []; /** @type {null | (() => void)} */ let releaseFirstOpen = null; let openGeneration = 0; let endCalls = 0; /** @type {null | (() => void)} */ let releaseRetryWrites = null; const retryWritesReleased = new Promise((resolve) => { releaseRetryWrites = resolve; }); const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); openGeneration += 1; const generation = openGeneration; if (generation === 1) { releaseFirstOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`open-created-1:${key}`); callback(null, Buffer.from(`handle-1:${key}`)); }; return; } remoteFiles.set(key, Buffer.from("retry-after-gate")); eventLog.push(`open-created-${generation}:${key}`); callback(null, Buffer.from(`handle-${generation}:${key}`)); }, write(handle, buffer, offset, length, position, callback) { void retryWritesReleased.then(() => { const key = String(handle).replace(/^handle-\d+:/, ""); const current = remoteFiles.get(key) || Buffer.alloc(0); const end = position + length; const next = Buffer.alloc(Math.max(current.length, end)); current.copy(next); buffer.copy(next, position, offset, offset + length); remoteFiles.set(key, next); setImmediate(() => callback(null)); }); }, close(handle, callback) { eventLog.push(`close:${String(handle)}`); callback(null); }, unlink(remotePath, callback) { eventLog.push(`unlink:${String(remotePath)}`); remoteFiles.delete(String(remotePath)); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { remoteFiles.delete(String(remotePath)); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstSender = createSender(); // Do not emit channel error: keep OPEN pending without force-complete so the // path gate stays held until the OPEN callback. const firstRunning = transferBridge.startTransfer( { sender: firstSender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000); assert.ok(firstOpenReady, "expected first shared write OPEN to stall"); // Start same-id retry while first OPEN is still pending (gate held). // Overwrite activeTransfers; first attempt is still awaiting OPEN. transferBridge.clearPendingCancel(transferId); const retrySender = createSender(); const retryRunning = transferBridge.startTransfer( { sender: retrySender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); await new Promise((resolve) => setTimeout(resolve, 300)); assert.equal( eventLog.some((entry) => entry.startsWith("open-created-2:")), false, `retry must wait on path gate while prior truncating OPEN is pending, log=${eventLog.join(",")}`, ); // Settle first OPEN (cancel it via late path): release callback as success // but first transfer may still be the waiter — force cancel first attempt. await transferBridge.cancelTransfer(null, { transferId: `${transferId}-noop` }).catch(() => {}); // First attempt is no longer active (retry replaced it). Release OPEN. releaseFirstOpen?.(); const lateClose = await waitUntil( () => eventLog.some((entry) => entry.startsWith("close:handle-1:")), 2000, ); assert.ok(lateClose, `expected first OPEN to close after gate release, log=${eventLog.join(",")}`); const retryOpenReady = await waitUntil( () => eventLog.some((entry) => entry.startsWith("open-created-2:")), 3000, ); assert.ok( retryOpenReady, `expected retry OPEN after prior OPEN settled, log=${eventLog.join(",")}`, ); assert.ok( remoteFiles.get(deterministicStagePath)?.equals(Buffer.from("retry-after-gate")), "retry stage must be written by retry OPEN after gate, not wiped by ordering race", ); releaseRetryWrites?.(); await transferBridge.cancelTransfer(null, { transferId }); const retryResult = await Promise.race([ retryRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("retry hung after path-gate test")), 5000); }), ]); assert.ok( retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")), `expected retry cancel settle, result=${JSON.stringify(retryResult)}`, ); // First attempt may still be pending cancel/error; do not leave it hanging. void firstRunning.catch(() => {}); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); }); test("remoteOpenPathMatchesStaged compares logical and encoded OPEN paths", () => { // Codex: filePath can be a session-encoded Buffer while stagedRemote.path is // the logical string — strict === would fail and wrongly allow unlink of the // retry-owned stage. const match = transferBridge._remoteOpenPathMatchesStagedForTests; assert.equal(typeof match, "function"); const logical = "/tmp/.upload.bin.netcatty-tid.part"; assert.equal( match(logical, { path: logical, sftpId: null }), true, "identical logical strings match", ); assert.equal( match(Buffer.from(logical), { path: logical, sftpId: null }), true, "OPEN Buffer of logical path matches staged logical path", ); assert.equal( match(Buffer.from(logical), { path: logical, sftpId: "missing-session", encoding: "utf-8", }), true, "utf-8 encode of logical path still matches OPEN Buffer", ); assert.equal( match("/tmp/other.part", { path: logical, sftpId: null }), false, "different paths must not match", ); assert.equal( match(logical, null), false, "missing stagedRemote must not match", ); assert.equal( match(logical, { path: "" }), false, "empty staged path must not match", ); }); test("late shared OPEN unlink still cleans stage when same-id retry is in-place", async (t) => { // Codex P2 on 39e20bfa: skip late unlink only when active.stagedRemote.path // matches. A same-id in-place retry (symlink destination → writeInPlace) // keeps resumable + targetPath but stagedRemote is null; a stale late OPEN // on the leftover `.netcatty-.part` must still unlink the orphan. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-inplace-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61)); const targetPath = "/tmp/upload-late-unlink-inplace.bin"; const transferId = "upload-shared-open-late-unlink-inplace"; const deterministicStagePath = `/tmp/.upload-late-unlink-inplace.bin.netcatty-${transferId}.part`; const remoteFiles = new Map(); // After the first attempt fails, the destination appears as a symlink so the // retry is planned in-place (no stagedRemote). let destinationIsSymlink = false; const eventLog = []; /** @type {null | (() => void)} */ let releaseFirstOpen = null; let openGeneration = 0; let endCalls = 0; /** @type {null | (() => void)} */ let releaseRetryWrites = null; const retryWritesReleased = new Promise((resolve) => { releaseRetryWrites = resolve; }); const sharedSftp = createFastSftp({ lstat(remotePath, callback) { const key = String(remotePath); if (destinationIsSymlink && key === targetPath) { callback(null, { size: 0, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); return; } if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o100644, isDirectory: () => false, isSymbolicLink: () => false, }); }, open(remotePath, flags, callback) { assert.equal(flags, "w"); const key = String(remotePath); openGeneration += 1; const generation = openGeneration; if (generation === 1) { assert.equal(key, deterministicStagePath, "first attempt must OPEN deterministic resume stage"); releaseFirstOpen = () => { if (!remoteFiles.has(key)) { remoteFiles.set(key, Buffer.alloc(0)); } eventLog.push(`open-created-1:${key}`); callback(null, Buffer.from(`handle-1:${key}`)); }; return; } // In-place retry opens the final target, not the stage. assert.equal(key, targetPath, "in-place retry must OPEN final target"); remoteFiles.set(key, Buffer.from("retry-inplace")); eventLog.push(`open-created-${generation}:${key}`); callback(null, Buffer.from(`handle-${generation}:${key}`)); }, write(handle, buffer, offset, length, position, callback) { void retryWritesReleased.then(() => { const key = String(handle).replace(/^handle-\d+:/, ""); const current = remoteFiles.get(key) || Buffer.alloc(0); const end = position + length; const next = Buffer.alloc(Math.max(current.length, end)); current.copy(next); buffer.copy(next, position, offset, offset + length); remoteFiles.set(key, next); eventLog.push(`write:${key}:${length}@${position}`); setImmediate(() => callback(null)); }); }, close(handle, callback) { eventLog.push(`close:${String(handle)}`); callback(null); }, unlink(remotePath, callback) { const key = String(remotePath); eventLog.push(`unlink:${key}`); remoteFiles.delete(key); callback(null); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete(remotePath) { const key = String(remotePath); eventLog.push(`delete:${key}`); remoteFiles.delete(key); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const firstSender = createSender(); const firstRunning = transferBridge.startTransfer( { sender: firstSender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000); assert.ok(firstOpenReady, "expected first shared write OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback")); const firstResult = await Promise.race([ firstRunning, new Promise((_, reject) => { setTimeout( () => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")), 5000, ); }), ]); assert.ok(firstResult.error, "expected first transfer to fail after channel error"); assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i); // Force the retry into writeInPlace (symlink) while still resumable. destinationIsSymlink = true; // Seed a leftover stage orphan the late OPEN would otherwise protect via // resumable+targetPath heuristics. remoteFiles.set(deterministicStagePath, Buffer.from("orphan-stage")); transferBridge.clearPendingCancel(transferId); const retrySender = createSender(); const retryRunning = transferBridge.startTransfer( { sender: retrySender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: TRANSFER_CHUNK_SIZE, resumable: true, skipAdmission: true, }, ); const retryOpenReady = await waitUntil( () => eventLog.some((entry) => entry.startsWith("open-created-2:")), 3000, ); assert.ok(retryOpenReady, `expected in-place retry OPEN, log=${eventLog.join(",")}`); assert.ok( remoteFiles.has(deterministicStagePath), "orphan stage must still exist before stale late OPEN", ); // Stale late OPEN from attempt 1 on the stage path: must close + unlink even // though a same-id resumable retry owns activeTransfers (in-place, no stage). releaseFirstOpen?.(); const lateCleanup = await waitUntil( () => eventLog.some((entry) => entry.startsWith("close:handle-1:")) && eventLog.some((entry) => entry === `unlink:${deterministicStagePath}`), 2000, ); assert.ok( lateCleanup, `expected late close+unlink of orphan stage under in-place retry, log=${eventLog.join(",")}`, ); assert.equal( remoteFiles.has(deterministicStagePath), false, "orphan stage must be unlinked when retry is not using it", ); assert.ok( remoteFiles.has(targetPath), "in-place retry final target must not be unlinked", ); releaseRetryWrites?.(); await transferBridge.cancelTransfer(null, { transferId }); const retryResult = await Promise.race([ retryRunning, new Promise((_, reject) => { setTimeout(() => reject(new Error("retry transfer hung after cancel")), 5000); }), ]); assert.ok( retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")), `expected retry cancel settle, result=${JSON.stringify(retryResult)}`, ); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); }); test("sudo SFTP downloads prefer concurrent shared READ over serial createReadStream", async (t) => { // Sudo cannot open an isolated channel, so downloads used to fall straight to // createReadStream (1-in-flight READs → hundreds of KB/s). Uploads already // keep pipelined WRITE fanout on the shared browse channel; downloads must // mirror that for the elevated SFTP path (#2719). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-download-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 47); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; const targetPath = path.join(tempDir, "download.bin"); let maxInFlight = 0; let activeReads = 0; let createReadStreamCalls = 0; let endCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { activeReads += 1; maxInFlight = Math.max(maxInFlight, activeReads); const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => { activeReads -= 1; callback(null, slice.length); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not be used when concurrent READ works"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-sudo-shared-concurrent", sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined, result.error); assert.equal(createReadStreamCalls, 0); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.ok( maxInFlight >= 2, `expected pipelined READ concurrency >= 2, got ${maxInFlight}`, ); assert.ok( maxInFlight <= DOWNLOAD_TRANSFER_CONCURRENCY, `expected concurrency <= ${DOWNLOAD_TRANSFER_CONCURRENCY}, got ${maxInFlight}`, ); const downloaded = await fs.promises.readFile(targetPath); assert.deepEqual(downloaded, payload); }); test("non-sudo downloads use concurrent shared READ when isolated channel is unavailable", async (t) => { // Isolated-channel miss (pool full / open unavailable) must still pipeline // READs on the browse channel — same fail-closed contract as uploads (#2449). // Serial createReadStream is never a silent bulk-transfer fallback (#2719). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonsudo-shared-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 19); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 197; const targetPath = path.join(tempDir, "download.bin"); let openCalls = 0; let maxInFlight = 0; let activeReads = 0; let createReadStreamCalls = 0; let endCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { openCalls += 1; assert.equal(flags, "r"); callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { activeReads += 1; maxInFlight = Math.max(maxInFlight, activeReads); const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => { activeReads -= 1; callback(null, slice.length); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after isolated miss"); }, end() { endCalls += 1; }, }); const client = { // No client.client → openIsolatedSftpChannel returns null (isolated miss). sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-nonsudo-isolated-miss-shared", sourcePath: "/home/user/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined, result.error); assert.ok(openCalls >= 1, "non-sudo isolated miss must use shared concurrent OPEN"); assert.equal(createReadStreamCalls, 0, "serial createReadStream must not run"); assert.equal(endCalls, 0, "browse channel must not be ended"); assert.ok( maxInFlight >= 2, `expected pipelined READ concurrency >= 2, got ${maxInFlight}`, ); const downloaded = await fs.promises.readFile(targetPath); assert.deepEqual(downloaded, payload); }); test("second concurrent sudo download waits for shared fast slot (no serial stream)", async (t) => { // FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507) still caps concurrent fanout to // one file; a second download waits for the slot instead of crawling via // serial createReadStream (#2719 fail-closed alignment). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-slot-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payloadA = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 11); for (let index = 0; index < payloadA.length; index += 1) payloadA[index] = index % 211; const payloadB = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 22); for (let index = 0; index < payloadB.length; index += 1) payloadB[index] = (index * 3) % 223; const targetA = path.join(tempDir, "a.bin"); const targetB = path.join(tempDir, "b.bin"); let endCalls = 0; let createReadStreamCalls = 0; let openBCalls = 0; let activeBReads = 0; let maxInFlightB = 0; const pendingReadCallbacks = []; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "r"); if (String(remotePath).includes("b.bin")) openBCalls += 1; callback(null, Buffer.from(`handle:${remotePath}`)); }, read(handle, buffer, offset, length, position, callback) { const key = handle.toString(); const payload = key.includes("b.bin") ? payloadB : payloadA; // Stall first transfer READs so it keeps the shared fast slot until released. if (!key.includes("b.bin")) { pendingReadCallbacks.push(() => { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); callback(null, slice.length); }); return; } activeBReads += 1; maxInFlightB = Math.max(maxInFlightB, activeBReads); const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => { activeBReads -= 1; callback(null, slice.length); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run while waiting for fast slot"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat(remotePath) { const size = String(remotePath).includes("b.bin") ? payloadB.length : payloadA.length; return Promise.resolve({ size, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const first = transferBridge.startTransfer( { sender }, { transferId: "download-sudo-slot-first", sourcePath: "/root/a.bin", targetPath: targetA, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payloadA.length, resumable: true, skipAdmission: true, }, ); const firstReady = await waitUntil(() => pendingReadCallbacks.length >= 1, 2000); assert.ok(firstReady, "expected first sudo download to hold shared READ slot"); const secondPromise = transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-sudo-slot-second", sourcePath: "/root/b.bin", targetPath: targetB, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payloadB.length, resumable: true, skipAdmission: true, }, ); // Condition-based wait: second must enter the session slot queue. assert.ok( await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000), "second download must enqueue on the session fast slot", ); assert.equal(openBCalls, 0, "second download must wait for shared fast slot"); assert.equal(createReadStreamCalls, 0, "serial stream must not run while waiting"); // Release first transfer's stalled READs so it can finish and free the slot. const drainPending = () => { for (const release of pendingReadCallbacks.splice(0)) { try { release(); } catch { /* ignore */ } } }; // Keep draining late-arriving first-transfer READs until it settles. const drainTimer = setInterval(drainPending, 5); t.after(() => clearInterval(drainTimer)); drainPending(); const firstResult = await Promise.race([ first, new Promise((_, reject) => { setTimeout(() => reject(new Error("first transfer did not settle after READ release")), 5000); }), ]); assert.equal(firstResult.error, undefined, firstResult.error); const second = await Promise.race([ secondPromise, new Promise((_, reject) => { setTimeout(() => reject(new Error("second transfer did not settle after slot free")), 5000); }), ]); assert.equal(second.error, undefined, second.error); assert.ok(openBCalls >= 1, "second download must use concurrent OPEN after slot free"); assert.ok( maxInFlightB >= 2, `second download must pipeline READs after slot free, got maxInFlightB=${maxInFlightB}`, ); assert.equal(createReadStreamCalls, 0, "serial createReadStream must never run"); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(client), 0); const downloadedA = await fs.promises.readFile(targetA); const downloadedB = await fs.promises.readFile(targetB); assert.deepEqual(downloadedA, payloadA); assert.deepEqual(downloadedB, payloadB); }); test("cancel while waiting for session fast download slot settles without serial stream", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-slot-wait-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payloadA = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 7); const payloadB = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 9); const pendingA = []; let endCalls = 0; let createReadStreamCalls = 0; let openBCalls = 0; const sharedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "r"); if (String(remotePath).includes("b.bin")) openBCalls += 1; callback(null, Buffer.from(`handle:${remotePath}`)); }, read(handle, buffer, offset, length, position, callback) { const key = handle.toString(); if (key.includes("b.bin")) { throw new Error("second download must not READ after cancel on slot wait"); } pendingA.push(() => { buffer.fill(7, offset, offset + length); callback(null, length); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat(remotePath) { const size = String(remotePath).includes("b.bin") ? payloadB.length : payloadA.length; return Promise.resolve({ size, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1 }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const first = transferBridge.startTransfer( { sender: createSender() }, { transferId: "slot-hold-first", sourcePath: "/root/a.bin", targetPath: path.join(tempDir, "a.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payloadA.length, resumable: true, skipAdmission: true, }, ); assert.ok(await waitUntil(() => pendingA.length >= 1, 2000)); const secondPromise = transferBridge.startTransfer( { sender: createSender() }, { transferId: "slot-wait-cancel-second", sourcePath: "/root/b.bin", targetPath: path.join(tempDir, "b.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payloadB.length, resumable: true, skipAdmission: true, }, ); assert.ok( await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000), "second must wait on session slot", ); await transferBridge.cancelTransfer(null, { transferId: "slot-wait-cancel-second" }); const second = await Promise.race([ secondPromise, new Promise((_, reject) => setTimeout(() => reject(new Error("slot-wait cancel hung")), 3000)), ]); assert.match(second.error || "", /cancel/i); assert.equal(openBCalls, 0); assert.equal(createReadStreamCalls, 0); assert.equal(endCalls, 0); assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(client), 0); // Holder still completes after stalled READs are released. const drain = setInterval(() => { for (const release of pendingA.splice(0)) { try { release(); } catch { /* ignore */ } } }, 5); t.after(() => clearInterval(drain)); const firstResult = await Promise.race([ first, new Promise((_, reject) => setTimeout(() => reject(new Error("holder hung")), 5000)), ]); assert.equal(firstResult.error, undefined, firstResult.error); }); test("resumable concurrent download range failure fails closed (no serial stream)", async (t) => { // Mirror upload fail-closed: when concurrent strategies fail, never complete // via createReadStream crawl. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 41); const targetPath = path.join(tempDir, "download.bin"); await fs.promises.writeFile(targetPath, Buffer.from("original")); let createReadStreamCalls = 0; let secondReadCallback = null; const isolated = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("iso-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (position === TRANSFER_CHUNK_SIZE) { secondReadCallback = callback; return; } payload.copy(buffer, offset, position, position + length); callback(null, length); if (position === 2 * TRANSFER_CHUNK_SIZE && secondReadCallback) { const cb = secondReadCallback; secondReadCallback = null; queueMicrotask(() => cb(new Error("second range failed"))); } }, close(_handle, callback) { callback(null); }, end() {}, }); const shared = createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("shared open also fails")); }, read() { throw new Error("shared READ must not run after OPEN failure"); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not complete the download"); }, }); const client = { sftp: shared, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, client: { sftp(callback) { callback(null, isolated); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-sparse-fail-closed", sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined download failed|second range failed|shared open also fails/i); assert.equal(createReadStreamCalls, 0); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original"); }); test("sudo concurrent READ failure fails closed (no createReadStream)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 55); const targetPath = path.join(tempDir, "download.bin"); let createReadStreamCalls = 0; let endCalls = 0; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("sudo-handle")); }, read(_handle, _buffer, _offset, _length, _position, callback) { setImmediate(() => callback(new Error("sudo READ rejected"))); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after sudo concurrent fail"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-sudo-fail-closed", sourcePath: "/root/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined download failed|sudo READ rejected/i); assert.equal(createReadStreamCalls, 0); assert.equal(endCalls, 0, "shared sudo channel must not be ended on fail-closed"); }); test("isolated download CLOSE timeout disposes channel instead of returning it to the pool", async (t) => { // Codex P2 on a3b11137: success-path CLOSE timeout with disposeChannel:true // only logged and left remoteCloseError unset, so downloadFile released the // isolated channel back to the pool without dispose. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-isolated-close-timeout-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 33); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 173; const targetPath = path.join(tempDir, "download.bin"); let openedChannels = 0; let endCalls = 0; let closeCalls = 0; const makeIsolated = () => createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from(`isolated-${openedChannels}`)); }, read(_handle, buffer, offset, length, position, callback) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, _callback) { closeCalls += 1; // Never invoke callback — simulates a hung CLOSE on an isolated channel. }, end() { endCalls += 1; }, }); const client = { sftp: createFastSftp({ createReadStream() { throw new Error("serial stream must not run when isolated concurrent READ succeeds"); }, }), stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, client: { sftp(callback) { openedChannels += 1; callback(null, makeIsolated()); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const first = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-isolated-close-timeout", sourcePath: "/tmp/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(first.error, undefined, first.error); assert.ok(closeCalls >= 1, "expected isolated CLOSE to be attempted"); assert.ok(endCalls >= 1, "CLOSE timeout must dispose the isolated channel"); assert.deepEqual(await fs.promises.readFile(targetPath), payload); // A follow-up download must open a fresh isolated channel, not reuse the timed-out one. const targetPath2 = path.join(tempDir, "download-2.bin"); const second = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-isolated-close-timeout-2", sourcePath: "/tmp/download.bin", targetPath: targetPath2, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(second.error, undefined, second.error); assert.ok(openedChannels >= 2, `expected a new isolated channel after dispose, got ${openedChannels}`); assert.deepEqual(await fs.promises.readFile(targetPath2), payload); }); test("cancel during stalled shared sudo download OPEN settles without sftp.end()", async (t) => { // Codex P2 on 2c369403: disposeChannel:false made pre-OPEN abort a no-op while // openSftpHandle had no cancel reject, so cancel hung forever and held the lease. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-open-stall-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const targetPath = path.join(tempDir, "download.bin"); const fileSize = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE; let endCalls = 0; let createReadStreamCalls = 0; let closeCalls = 0; let releaseOpen = null; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); releaseOpen = () => callback(null, Buffer.from("late-shared-handle")); }, read() { throw new Error("READ must not run after cancel during OPEN"); }, close(_handle, callback) { closeCalls += 1; callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after cancel"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: fileSize, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-sudo-open-stall-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: fileSize, resumable: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared OPEN to stall"); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout(() => reject(new Error("transfer did not settle after cancel during shared OPEN")), 3000); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended on cancel"); assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream"); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); // Late OPEN success after cancel should close the handle, not leave it open. releaseOpen?.(); await new Promise((resolve) => setImmediate(resolve)); assert.ok(closeCalls >= 1, `expected late shared handle close, got ${closeCalls}`); }); test("shared sudo download OPEN rejects on channel error without hanging", async (t) => { // Codex P2 on 50ef8fac: outer onChannelError only recorded the error for a // post-await check. If the shared channel emitted error while sftp.open was // pending and never invoked the OPEN callback, the transfer hung forever and // held the SFTP lease. openSftpHandleForTransfer must reject on channel error. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-open-channel-error-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const targetPath = path.join(tempDir, "download.bin"); const fileSize = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE; let endCalls = 0; let createReadStreamCalls = 0; let closeCalls = 0; let releaseOpen = null; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); releaseOpen = () => callback(null, Buffer.from("late-shared-handle-after-error")); }, read() { throw new Error("READ must not run after channel error during OPEN"); }, close(_handle, callback) { closeCalls += 1; callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after pipelined OPEN failure"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: fileSize, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-sudo-open-channel-error"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: fileSize, resumable: true, }, ); const ready = await waitUntil(() => typeof releaseOpen === "function", 2000); assert.ok(ready, "expected shared OPEN to stall"); sharedSftp.emit("error", new Error("shared SFTP channel died during OPEN")); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer did not settle after shared channel error during OPEN")), 3000, ); }), ]); assert.ok(result.error, "expected transfer to fail after channel error"); assert.match( result.error, /shared SFTP channel died during OPEN|pipelined download failed/i, ); assert.equal(endCalls, 0, "shared sudo channel must not be ended on channel error"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); assert.equal( createReadStreamCalls, 0, "concurrent shared OPEN failure must fail closed (no serial stream fallback)", ); // Late OPEN success after channel-error reject should close the handle. releaseOpen?.(); await new Promise((resolve) => setImmediate(resolve)); assert.ok(closeCalls >= 1, `expected late shared handle close, got ${closeCalls}`); }); test("cancel during stalled shared sudo READ settles without sftp.end()", async (t) => { // Codex P2 on d855e69c: disposeChannel:false passed forceSettleOnError:false, so // cancel while an sftp.read callback never arrives hung forever (abortChannel is // a no-op on shared channels). Downloads have no remote WRITEs to drain, so they // must force-settle on cancel without ending the sudo session. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-read-stall-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const targetPath = path.join(tempDir, "download.bin"); const fileSize = 4 * TRANSFER_CHUNK_SIZE; let endCalls = 0; let createReadStreamCalls = 0; let activeReads = 0; let maxInFlight = 0; const pendingReadCallbacks = []; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-read-handle")); }, read(_handle, _buffer, _offset, _length, _position, callback) { activeReads += 1; maxInFlight = Math.max(maxInFlight, activeReads); // Stall forever — never invoke callback (simulates dead/hung READ). pendingReadCallbacks.push(() => { activeReads -= 1; callback(new Error("late READ after cancel")); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after cancel"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: fileSize, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-sudo-read-stall-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: fileSize, resumable: true, }, ); const ready = await waitUntil(() => pendingReadCallbacks.length >= 1, 2000); assert.ok(ready, "expected at least one shared READ to stall"); assert.ok(maxInFlight >= 1, `expected in-flight READ, got ${maxInFlight}`); await transferBridge.cancelTransfer(null, { transferId }); // forceSettleOnError grace is 2s; shared cancel/fail CLOSE is fire-and-forget. const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer did not settle after cancel during stalled shared READ")), 5000, ); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended on cancel during READ"); assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream"); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); // Late READ callbacks after settle must not crash the process. for (const release of pendingReadCallbacks.splice(0)) { try { release(); } catch { /* ignore */ } } await new Promise((resolve) => setImmediate(resolve)); }); test("cancel during stalled shared verification READ settles without sftp.end()", async (t) => { // Codex P2 on cba714f3: after ranged READs finish, verifyFastDownloadSamples still // awaits bare sftp.read outside forceSettleOnError. Shared cancel cannot // sftp.end(), so a hung verification READ would hold the transfer and lease. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-stall-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 61); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; const targetPath = path.join(tempDir, "download.bin"); let endCalls = 0; let createReadStreamCalls = 0; let transferredBytes = 0; const pendingVerifyCallbacks = []; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-verify-handle")); }, read(_handle, buffer, offset, length, position, callback) { // Complete the concurrent download first; stall only verification samples. if (transferredBytes < payload.length) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); transferredBytes += slice.length; setImmediate(() => callback(null, slice.length)); return; } pendingVerifyCallbacks.push(() => { callback(new Error("late verification READ after cancel")); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after cancel"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-sudo-verify-stall-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); const ready = await waitUntil(() => pendingVerifyCallbacks.length >= 1, 3000); assert.ok(ready, "expected verification READ to stall after ranged download"); await transferBridge.cancelTransfer(null, { transferId }); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer did not settle after cancel during stalled verification READ")), 5000, ); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended during verification cancel"); assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream"); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); for (const release of pendingVerifyCallbacks.splice(0)) { try { release(); } catch { /* ignore */ } } await new Promise((resolve) => setImmediate(resolve)); }); test("cancel during responsive last verification sample reports cancelled", async (t) => { // Codex P2 on 74ae5e0a: cancel during the last sample can let the READ win the // race before the 2s force-settle fires. Without a post-race cancelled check, // verification returns successfully and sendComplete() reports done. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-race-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 67); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; const targetPath = path.join(tempDir, "download.bin"); let endCalls = 0; let transferredBytes = 0; let verifyReadCount = 0; let releaseLastVerifyRead = null; const lastVerifyReadHeld = new Promise((resolve) => { releaseLastVerifyRead = resolve; }); const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-verify-race-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (transferredBytes < payload.length) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); transferredBytes += slice.length; setImmediate(() => callback(null, slice.length)); return; } verifyReadCount += 1; const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); // Hold only the last sample briefly so cancel can land mid-READ, then // complete successfully (responsive server — force-settle never fires). if (verifyReadCount >= 3) { lastVerifyReadHeld.then(() => { setImmediate(() => callback(null, slice.length)); }); return; } setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial createReadStream must not run"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const transferId = "download-sudo-verify-race-cancel"; const running = transferBridge.startTransfer( { sender }, { transferId, sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); const ready = await waitUntil(() => verifyReadCount >= 3, 3000); assert.ok(ready, "expected last verification sample to be held"); await transferBridge.cancelTransfer(null, { transferId }); releaseLastVerifyRead(); const result = await Promise.race([ running, new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer did not settle after cancel during last verification sample")), 3000, ); }), ]); assert.match(result.error || "", /cancel/i); assert.equal(endCalls, 0, "shared sudo channel must not be ended"); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled")); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false); }); test("shared verification channel error between samples fails without hanging", async (t) => { // Codex P2 on 14783168: channel error between sample races (after one sample // resolves, before the next rejectPending is installed) was dropped; the next // readSftpRange could hang forever on a dead shared channel. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-gap-error-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 71); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; const targetPath = path.join(tempDir, "download.bin"); let endCalls = 0; let transferredBytes = 0; let verifyReadCount = 0; let sharedSftp = null; const pendingSecondVerify = []; sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-verify-gap-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (transferredBytes < payload.length) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); transferredBytes += slice.length; setImmediate(() => callback(null, slice.length)); return; } verifyReadCount += 1; if (verifyReadCount === 1) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => { callback(null, slice.length); // Emit after the first sample resolves and before the next race installs // rejectPending — the gap Codex identified. setImmediate(() => { sharedSftp.emit("error", new Error("shared channel died between samples")); }); }); return; } // Subsequent verification READs never callback (dead channel). pendingSecondVerify.push(callback); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial createReadStream must not run after channel error"); }, end() { endCalls += 1; }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1, }); }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const result = await Promise.race([ transferBridge.startTransfer( { sender }, { transferId: "download-sudo-verify-gap-error", sourcePath: "/root/download.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ), new Promise((_, reject) => { setTimeout( () => reject(new Error("transfer hung after shared verification channel error between samples")), 4000, ); }), ]); // Concurrent path already staged the full file, so fallback may complete via // the checkpoint>=fileSize short-circuit — either outcome is fine as long as // we settle instead of hanging on the dead-channel READ. assert.ok(result, "expected transfer to settle after inter-sample channel error"); assert.equal(endCalls, 0, "shared sudo channel must not be ended on verification error"); assert.ok( sender.sent.some((entry) => ( entry.channel === "netcatty:transfer:complete" || entry.channel === "netcatty:transfer:error" || entry.channel === "netcatty:transfer:cancelled" )), "expected a terminal transfer event after channel error between samples", ); }); test("shared upload errors wait for all in-flight WRITEs before returning", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-error-drain-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const fileSize = 4 * TRANSFER_CHUNK_SIZE; const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, Buffer.alloc(fileSize, 13)); const pendingWriteCallbacks = []; let triggerFirstError; const firstErrorTriggered = new Promise((resolve) => { triggerFirstError = resolve; }); let firstErrorScheduled = false; const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "w"); callback(null, Buffer.from("shared-handle")); }, write(_handle, _buffer, _offset, _length, _position, callback) { pendingWriteCallbacks.push(callback); if (!firstErrorScheduled && pendingWriteCallbacks.length === 4) { firstErrorScheduled = true; setImmediate(() => { const fail = pendingWriteCallbacks.shift(); fail(new Error("shared WRITE failed")); triggerFirstError(); }); } }, close(_handle, callback) { callback(null); }, }); const client = { __netcattySudoMode: true, sftp: sharedSftp, stat() { return Promise.resolve({ size: 0 }); }, delete() { return Promise.resolve(); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); let transferSettled = false; const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "upload-shared-error-drain", sourcePath: localPath, targetPath: "/tmp/upload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: fileSize, resumable: true, }, ).finally(() => { transferSettled = true; }); await firstErrorTriggered; await new Promise((resolve) => setTimeout(resolve, 2100)); const settledBeforeRemainingWrites = transferSettled; for (const callback of pendingWriteCallbacks.splice(0)) callback(null); const result = await running; assert.equal(settledBeforeRemainingWrites, false); assert.match(result.error || "", /shared WRITE failed|pipelined upload failed/i); }); test("sudo SFTP sessions without open/write fail closed (no serial WriteStream)", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-fail-closed-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const localPath = path.join(tempDir, "payload.bin"); const payload = Buffer.alloc(64 * 1024, 7); await fs.promises.writeFile(localPath, payload); let createWriteStreamCalls = 0; const streamOnlySftp = createFastSftp({ createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not run"); }, }); const client = { // Isolated channel skipped; shared sftp has only createWriteStream. __netcattySudoMode: true, sftp: streamOnlySftp, stat() { return Promise.resolve({ size: 0 }); }, rename() { return Promise.resolve(); }, delete() { return Promise.resolve(); }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const result = await transferBridge.startTransfer( { sender }, { transferId: "upload-sudo-fail-closed", sourcePath: localPath, targetPath: "/tmp/payload.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, }, ); assert.match(result.error || "", /pipelined upload failed|open\/write missing/i); assert.equal(createWriteStreamCalls, 0); assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error")); }); test("resumable SFTP downloads preserve a 2MB request window on high-latency paths", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(4 * 1024 * 1024, 7); let activeReads = 0; let observedConcurrency = 0; let observedChunkSize = 0; const fastSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { activeReads += 1; observedConcurrency = Math.max(observedConcurrency, activeReads); observedChunkSize = Math.max(observedChunkSize, length); payload.copy(buffer, offset, position, position + length); setImmediate(() => { activeReads -= 1; callback(null, length, buffer, position); }); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({ createReadStream() { return Readable.from(payload); }, }), stat(_path) { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const result = await transferBridge.startTransfer( { sender }, { transferId: "download-large", sourcePath: "/tmp/large.bin", targetPath: path.join(tempDir, "large.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", resumable: true, }, ); assert.equal(result.error, undefined); assert.equal(observedChunkSize, 32 * 1024); assert.equal(observedConcurrency * observedChunkSize, 2 * 1024 * 1024); assert.deepEqual(await fs.promises.readFile(path.join(tempDir, "large.bin")), payload); }); function runIsolatedStagedDownloadRecovery(mode) { const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-transfer-root-rebind-${mode}-`)); const home = path.join(root, "home"); fs.mkdirSync(home); try { const script = ` const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { EventEmitter } = require("node:events"); const transferBridge = require("./electron/bridges/transferBridge.cjs"); const tempDirBridge = require("./electron/bridges/tempDirBridge.cjs"); const mode = process.env.NETCATTY_TEST_REBIND_MODE; const transferId = mode === "after-open" ? "download-root-rebind-after-open" : "download-root-rebind-before-write"; const payload = Buffer.from( mode === "after-open" ? "recovered after stage open" : "recovered staged download", ); const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "target-")); const targetPath = path.join(targetDir, "download.bin"); const initialStagedPath = tempDirBridge.getTransferTempFilePath(transferId, "download.bin"); const tempRoot = path.dirname(initialStagedPath); const initialGeneration = tempDirBridge.getTempDirRebindGeneration(); let triggered = false; const originalGetTransferTempFilePath = tempDirBridge.getTransferTempFilePath; const originalOpen = fs.promises.open; if (mode === "after-open") { fs.promises.open = async (filePath, ...args) => { const opened = await originalOpen(filePath, ...args); if (!triggered && path.resolve(filePath) === path.resolve(initialStagedPath)) { triggered = true; fs.renameSync(tempRoot, tempRoot + ".after-open-old"); fs.mkdirSync(tempRoot, { recursive: true, mode: 0o700 }); } return opened; }; } else { tempDirBridge.getTransferTempFilePath = (id, fileName) => { const stagedPath = originalGetTransferTempFilePath(id, fileName); if (!triggered) { triggered = true; fs.rmSync(tempRoot, { recursive: true, force: true }); } return stagedPath; }; } const sftp = new EventEmitter(); sftp.open = (_remotePath, flags, callback) => { if (typeof flags === "function") callback = flags; callback(null, Buffer.from("read-handle")); }; sftp.read = (_handle, buffer, offset, length, position, callback) => { const end = Math.min(position + length, payload.length); payload.copy(buffer, offset, position, end); setImmediate(() => callback(null, end - position)); }; sftp.close = (_handle, callback) => callback(null); sftp.readdir = (_remotePath, callback) => callback(null, []); sftp.stat = (_remotePath, callback) => callback(null, { size: payload.length }); sftp.lstat = (_remotePath, callback) => { const error = new Error("ENOENT"); error.code = 2; callback(error); }; sftp.mkdir = (_remotePath, callback) => callback(null); sftp.unlink = (_remotePath, callback) => callback(null); sftp.end = () => {}; const client = { sftp, stat: () => Promise.resolve({ size: payload.length }), }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); (async () => { const result = await transferBridge.startTransfer( { sender: { send() {} } }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); const content = fs.existsSync(targetPath) ? await fs.promises.readFile(targetPath, "utf8") : null; console.log("RESULT:" + JSON.stringify({ error: result.error || null, content, triggered, tempRoot, generation: tempDirBridge.getTempDirRebindGeneration(), initialGeneration, })); })().catch(error => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, ["-e", script], { cwd: path.resolve(__dirname, "../.."), env: { ...process.env, TMPDIR: root, TEMP: root, TMP: root, HOME: home, NETCATTY_TEST_REBIND_MODE: mode, }, encoding: "utf8", }); assert.equal(result.status, 0, result.stderr || result.stdout); const match = result.stdout.match(/^RESULT:(.+)$/m); assert.ok(match, result.stdout); const report = JSON.parse(match[1]); assert.equal(path.dirname(report.tempRoot), root); return report; } finally { fs.rmSync(root, { recursive: true, force: true }); } } test("staged SFTP downloads recover when the temp root disappears before the first write", () => { const result = runIsolatedStagedDownloadRecovery("before-write"); assert.equal(result.error, null); assert.equal(result.triggered, true); assert.equal(result.generation, result.initialGeneration + 1); assert.equal(result.content, "recovered staged download"); }); test("staged SFTP downloads recover when the temp root is replaced after opening the stage", () => { const result = runIsolatedStagedDownloadRecovery("after-open"); assert.equal(result.error, null); assert.equal(result.triggered, true); assert.equal(result.generation, result.initialGeneration + 1); assert.equal(result.content, "recovered after stage open"); }); test("fast resumable downloads pause only at a complete checkpoint", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fast-pause-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(4 * 1024 * 1024, 11); const pendingReads = []; let holdReads = true; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { const complete = () => { payload.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); }; if (holdReads) pendingReads.push(complete); else setImmediate(complete); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({ createReadStream() { return Readable.from(payload); }, }), stat() { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const targetPath = path.join(tempDir, "large.bin"); const running = transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-fast-paused", sourcePath: "/tmp/large.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingReads.length < 64 && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingReads.length, 64); const originalStat = fs.promises.stat; fs.promises.stat = async (filePath) => { if (String(filePath).includes("download-fast-paused")) { throw new Error("fast range checkpoints must not be inferred from file size"); } return originalStat(filePath); }; const pausing = transferBridge.pauseTransfer(null, { transferId: "download-fast-paused" }); holdReads = false; for (const complete of pendingReads.splice(0)) complete(); let paused; try { paused = await pausing; } finally { fs.promises.stat = originalStat; } assert.equal(paused.success, true); assert.equal(paused.checkpointBytes, 2 * 1024 * 1024); { const resumed = await transferBridge.resumeTransfer(null, { transferId: "download-fast-paused" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } assert.equal((await running).error, undefined); assert.deepEqual(await fs.promises.readFile(targetPath), payload); }); test("fast resumable downloads fall back from the highest contiguous checkpoint", async (t) => { // Isolated concurrent ranges fail mid-file; concurrent-shared resumes from the // highest contiguous checkpoint (no serial createReadStream fallback). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-contiguous-fallback-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(3 * 32 * 1024, 17); let sharedOpenCheckpoint = null; let secondReadCallback = null; let targetPath = null; let stagedPath = null; const progress = []; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (position === 32 * 1024) { secondReadCallback = callback; return; } payload.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); if (position === 2 * 32 * 1024) { queueMicrotask(() => secondReadCallback(new Error("second range failed"))); } }, close(_handle, callback) { callback(null); }, }); const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); try { sharedOpenCheckpoint = fs.statSync(stagedPath).size; } catch { sharedOpenCheckpoint = -1; } callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial createReadStream must not run after isolated range failure"); }, }); const client = { sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); targetPath = path.join(tempDir, "fallback.bin"); stagedPath = tempDirBridge.getTransferTempFilePath( "download-contiguous-fallback", path.basename(targetPath), ); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-contiguous-fallback", sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, (transferred) => progress.push(transferred), ); assert.equal(result.error, undefined, result.error); assert.equal(sharedOpenCheckpoint, 32 * 1024); const firstPastCheckpoint = progress.findIndex((transferred) => transferred > sharedOpenCheckpoint); assert.ok(firstPastCheckpoint >= 0); assert.ok(progress.slice(firstPastCheckpoint + 1).includes(sharedOpenCheckpoint)); assert.deepEqual(await fs.promises.readFile(targetPath), payload); }); test("resumable download fallback rejects a remote source changed during concurrent-shared", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-fallback-change-")); const transferId = "download-fallback-change"; const targetPath = path.join(tempDir, "download.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(stagedPath, { force: true }); }); await fs.promises.writeFile(targetPath, "original"); const payload = Buffer.alloc(32 * 1024, 51); let sourceChanged = false; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, _buffer, _offset, _length, _position, callback) { callback(new Error("range download unavailable")); }, }); const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); sourceChanged = true; callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial createReadStream must not run"); }, }); const client = { sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length, mtimeMs: sourceChanged ? 2 : 1, ctimeMs: sourceChanged ? 2 : 1, }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(sourceChanged, true); assert.match(result.error || "", /source.*changed/i); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original"); assert.equal(fs.existsSync(stagedPath), false); }); test("range-failure fallback truncates sparse local tail before concurrent-shared", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sparse-tail-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); // Two chunks: finish the second first, then fail the first so contiguous stays 0 // while the local file already has a sparse tail past the durable checkpoint. const chunk = 32 * 1024; const payload = Buffer.alloc(2 * chunk, 17); let firstReadCallback = null; let sizeAtSharedOpen = null; const transferId = "download-sparse-tail"; const targetPath = path.join(tempDir, "sparse.bin"); // Resumable downloads stage under the transfer temp path, not the final target. const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath)); const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, read(_handle, buffer, offset, length, position, callback) { if (position === 0) { firstReadCallback = () => callback(new Error("first range failed")); // Complete the later range first, then fail the first. queueMicrotask(() => { // second range is already in flight separately }); return; } payload.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); queueMicrotask(() => firstReadCallback?.()); }, close(_handle, callback) { callback(null); }, }); const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); // Capture *staged* size when concurrent-shared opens (post-truncate). try { sizeAtSharedOpen = fs.statSync(stagedPath).size; } catch { sizeAtSharedOpen = -1; } callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream() { throw new Error("serial createReadStream must not run after range failure"); }, }); const client = { sftp: sharedSftp, stat() { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(result.error, undefined, result.error); // Contiguous checkpoint never advanced past 0; sparse tail must be truncated // on the staged .part before concurrent-shared resumes (final target is only // written at promote time). assert.equal(sizeAtSharedOpen, 0); assert.deepEqual(await fs.promises.readFile(targetPath), payload); }); test("S2S upload-phase concurrent failure does not truncate the complete local temp source", async (t) => { const transferId = `s2s-no-truncate-${crypto.randomUUID()}`; const payload = Buffer.alloc(64 * 1024, 23); const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin"); await fs.promises.writeFile(localStage, payload); t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); }); let sizeAfterFail = null; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, _length, position, callback) { if (position === 0) { callback(new Error("first upload range failed")); return; } callback(null); }, close(_handle, callback) { callback(null); }, end() {}, }); const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }), }; const targetClient = { sftp: createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("shared open also fails")); }, createWriteStream() { throw new Error("serial WriteStream must not run"); }, }), stat: async () => ({ size: 0 }), rename: async () => {}, delete: async () => {}, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([ ["source", sourceClient], ["target", targetClient], ]), }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin", sourceType: "sftp", targetType: "sftp", sourceSftpId: "source", targetSftpId: "target", totalBytes: payload.length, resumable: true, resumeStage: "upload", downloadCheckpointBytes: payload.length, uploadCheckpointBytes: 0, checkpointBytes: 0, }, ); try { sizeAfterFail = fs.statSync(localStage).size; } catch { sizeAfterFail = -1; } // Fail closed — local S2S temp is the fully-downloaded source, never truncated. assert.match(result.error || "", /pipelined upload failed|first upload range failed/i); assert.equal(sizeAfterFail, payload.length); }); test("cancelled fast resumable downloads release their isolated channel", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-cancel-release-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 19); let openedChannels = 0; let fallbackReads = 0; const pendingReads = []; let lateReadScheduled = false; const firstChannel = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("first-handle")); }, read(_handle, _buffer, _offset, _length, _position, callback) { pendingReads.push(callback); }, end() { if (lateReadScheduled) return; lateReadScheduled = true; setTimeout(() => { pendingReads.shift()?.(new Error("late channel cancellation")); }, 1500); }, }); const secondChannel = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("second-handle")); }, read(_handle, buffer, offset, length, position, callback) { payload.copy(buffer, offset, position, position + length); callback(null, length, buffer, position); }, close(_handle, callback) { callback(null); }, }); const client = { sftp: createFastSftp({ createReadStream(_remotePath, options) { if (options?.start !== undefined) fallbackReads += 1; return Readable.from(payload); }, }), stat() { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { openedChannels += 1; callback(null, openedChannels === 1 ? firstChannel : secondChannel); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const first = transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-cancel-release-first", sourcePath: "/tmp/source.bin", targetPath: path.join(tempDir, "first.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); const readyDeadline = Date.now() + 1000; while (pendingReads.length < 2 && Date.now() < readyDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(pendingReads.length, 2); const cancelledAt = Date.now(); await transferBridge.cancelTransfer(null, { transferId: "download-cancel-release-first" }); assert.equal((await first).error, "Transfer cancelled"); const cancelDuration = Date.now() - cancelledAt; assert.ok(cancelDuration >= 1900); assert.ok(cancelDuration < 3000); const second = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-cancel-release-second", sourcePath: "/tmp/source.bin", targetPath: path.join(tempDir, "second.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, resumable: true, }, ); assert.equal(second.error, undefined); assert.equal(openedChannels, 2); assert.equal(fallbackReads, 0); }); test("SFTP downloads fall back to concurrent shared READ after fastGet fails", async (t) => { // Non-resumable fastGet failure must not crawl via serial createReadStream; // the next pipelined strategy is concurrent-shared READ on the browse channel. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fallback-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const expected = Buffer.from("complete fallback download"); let fastGetAttempts = 0; let createReadStreamCalls = 0; let maxInFlight = 0; let activeReads = 0; const fastSftp = createFastSftp({ fastGet(_remotePath, localPath, _options, done) { fastGetAttempts += 1; fs.promises.writeFile(localPath, "partial").then( () => done(new Error("server rejected concurrent reads")), done, ); }, }); const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, position, callback) { activeReads += 1; maxInFlight = Math.max(maxInFlight, activeReads); const slice = expected.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => { activeReads -= 1; callback(null, slice.length); }); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run after fastGet failure"); }, }); const client = { sftp: sharedSftp, stat() { return Promise.resolve({ size: expected.length }); }, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const targetPath = path.join(tempDir, "fallback.bin"); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-fallback", sourcePath: "/tmp/fallback.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: expected.length, }, ); assert.equal(result.error, undefined, result.error); assert.equal(fastGetAttempts, 1); assert.equal(createReadStreamCalls, 0); assert.ok(maxInFlight >= 1, "expected concurrent shared READ after fastGet failure"); assert.deepEqual(await fs.promises.readFile(targetPath), expected); }); test("SFTP downloads serialize concurrent files on the session fast-path slot", async (t) => { // FAST_DOWNLOAD_CHANNELS_PER_SESSION caps concurrent 64-READ fanout to one // file per session. A second download waits for the slot (pipelined path) // instead of crawling via serial createReadStream (#2719 / #1507). const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-budget-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const completions = []; let activeFastGets = 0; let maxActiveFastGets = 0; let openedChannels = 0; let createReadStreamCalls = 0; // Shared browse channel has open/read so a slot-waiter can still pipeline if // the isolated pool is empty after the first channel is disposed. For this // test both files complete via isolated fastGet after serializing on the slot. const sharedSftp = createFastSftp({ open(_remotePath, flags, callback) { assert.equal(flags, "r"); callback(null, Buffer.from("shared-read-handle")); }, read(_handle, buffer, offset, length, _position, callback) { buffer.fill(0x64, offset, offset + length); setImmediate(() => callback(null, length)); }, close(_handle, callback) { callback(null); }, createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not run under session slot wait"); }, }); const client = { sftp: sharedSftp, stat() { return Promise.resolve({ size: 10 }); }, client: { sftp(callback) { openedChannels += 1; // Fresh channel per open so the second transfer can acquire isolated // after the first returns its channel to the pool (or opens again). const channel = createFastSftp({ fastGet(_remotePath, localPath, _options, done) { activeFastGets += 1; maxActiveFastGets = Math.max(maxActiveFastGets, activeFastGets); completions.push(async () => { await fs.promises.writeFile(localPath, "downloaded"); activeFastGets -= 1; done(); }); }, }); callback(null, channel); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const start = (id) => transferBridge.startTransfer( { sender: createSender() }, { transferId: id, sourcePath: `/tmp/${id}.bin`, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 10, // Isolate the fast-channel budget test from global file admission. skipAdmission: true, }, ); const first = start("download-one"); const firstDeadline = Date.now() + 1000; while (completions.length < 1 && Date.now() < firstDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(completions.length, 1); assert.equal(openedChannels, 1); const secondPromise = start("download-two"); // Second must wait on the session slot — no second fastGet yet, no serial stream. assert.ok( await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000), "second download must enqueue on the session fast slot", ); assert.equal(completions.length, 1, "second download must wait for session fast slot"); assert.equal(createReadStreamCalls, 0); await completions[0](); assert.equal((await first).error, undefined); // After first releases the slot, second proceeds on the pipelined path. const secondDeadline = Date.now() + 1000; while (completions.length < 2 && Date.now() < secondDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(completions.length, 2, "second download should start after slot free"); await completions[1](); const secondResult = await secondPromise; assert.equal(secondResult.error, undefined, secondResult.error); assert.equal(createReadStreamCalls, 0, "serial createReadStream must never run"); assert.equal(maxActiveFastGets, 1, "only one concurrent fastGet at a time"); }); test("idle fast-download channels are discarded when a delayed error arrives", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-idle-error-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.from("downloaded"); const channels = []; let endedChannels = 0; const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: payload.length }); }, client: { sftp(callback) { const channel = createFastSftp({ fastGet(_remotePath, localPath, _options, done) { fs.promises.writeFile(localPath, payload).then(() => done(), done); }, end() { endedChannels += 1; }, }); channels.push(channel); callback(null, channel); }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const start = (id) => transferBridge.startTransfer( { sender: createSender() }, { transferId: id, sourcePath: `/tmp/${id}.bin`, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: payload.length, }, ); assert.equal((await start("idle-error-first")).error, undefined); assert.equal(channels.length, 1); assert.ok(channels[0].listenerCount("error") > 0); channels[0].emit("error", new Error("delayed idle failure")); assert.equal(endedChannels, 1); assert.equal((await start("idle-error-second")).error, undefined); assert.equal(channels.length, 2); channels[1].emit("error", new Error("test cleanup")); }); test("SFTP downloads cancelled while opening do not block the session", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-open-cancel-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); let delayedOpen = null; let abandonedChannelClosed = false; let openCalls = 0; let physicalEndCalls = 0; let physicalDestroyCalls = 0; const abandonedSftp = createFastSftp({ end() { abandonedChannelClosed = true; }, }); const workingSftp = createFastSftp({ fastGet(_remotePath, localPath, _options, done) { fs.promises.writeFile(localPath, "downloaded").then( () => done(), done, ); }, }); const client = { sftp: createFastSftp({}), stat() { return Promise.resolve({ size: 10 }); }, client: { end() { physicalEndCalls++; }, destroy() { physicalDestroyCalls++; }, sftp(callback) { openCalls += 1; if (openCalls === 1) { delayedOpen = callback; } else { callback(null, workingSftp); } }, }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const start = (id) => transferBridge.startTransfer( { sender: createSender() }, { transferId: id, sourcePath: `/tmp/${id}.bin`, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 10, }, ); const cancelledPromise = start("download-opening"); const deadline = Date.now() + 1000; while (!delayedOpen && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.equal(typeof delayedOpen, "function"); await transferBridge.cancelTransfer(null, { transferId: "download-opening" }); delayedOpen(null, abandonedSftp); const cancelled = await cancelledPromise; assert.equal(cancelled.error, "Transfer cancelled"); assert.equal(abandonedChannelClosed, true); assert.equal(physicalEndCalls, 0, "cancelling one download must preserve the shared SSH connection"); assert.equal(physicalDestroyCalls, 0, "terminal and browsing channels must not be destroyed"); const next = await Promise.race([ start("download-after-cancel"), new Promise((_, reject) => setTimeout(() => reject(new Error("next download remained blocked")), 1000)), ]); assert.equal(next.error, undefined); assert.equal(openCalls, 2); }); test("shared concurrent downloads fail closed when open/read are missing (no createReadStream)", async (t) => { // Code-level lock: bulk download must not reintroduce serial createReadStream. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-no-stream-body-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); let createReadStreamCalls = 0; const sftp = createFastSftp({ // Intentionally no open/read — pipelined path unavailable. createReadStream() { createReadStreamCalls += 1; throw new Error("serial createReadStream must not be used for bulk SFTP download"); }, }); const client = { sftp, stat() { return Promise.resolve({ size: 6 }); }, client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId: "download-no-stream-body", sourcePath: "/tmp/source.bin", targetPath: path.join(tempDir, "target.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 6, resumable: true, }, ); assert.match(result.error || "", /pipelined download failed|open\/read missing/i); assert.equal(createReadStreamCalls, 0, "bulk path must never call createReadStream"); }); test("stream local-copy pause survives write-stream drain without auto-resuming the pipe", async (t) => { // Regression for transfer-list pause (#2458): Node's .pipe() resumes the source // on destination 'drain'. SFTP upload no longer uses serial WriteStream; cover // unpipe on local→local copies (same pauseTransfer path). Mock write stream // still persists bytes so pause checkpoint stat succeeds. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pipe-pause-local-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(256 * 1024, 91); const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(sourcePath, payload); let durableBytes = 0; const pendingWriteCallbacks = []; let holdWrites = true; let activeWriteStream = null; const originalCreateWriteStream = fs.createWriteStream; t.after(() => { fs.createWriteStream = originalCreateWriteStream; }); fs.createWriteStream = (filePath, options) => { const real = originalCreateWriteStream(filePath, options); activeWriteStream = new Writable({ highWaterMark: 16, write(chunk, encoding, callback) { real.write(chunk, encoding, (err) => { if (err) return callback(err); durableBytes += chunk.length; activeWriteStream.bytesWritten = durableBytes; if (holdWrites) { pendingWriteCallbacks.push(callback); return; } callback(); }); }, final(callback) { real.end(callback); }, destroy(err, callback) { real.destroy(err); callback(err); }, }); activeWriteStream.bytesWritten = 0; return activeWriteStream; }; transferBridge.init({ sftpClients: new Map() }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId: "local-pipe-pause", sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: payload.length, resumable: true, }, ); const backpressureDeadline = Date.now() + 2000; while (pendingWriteCallbacks.length === 0 && Date.now() < backpressureDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.ok(pendingWriteCallbacks.length > 0, "write stream should be backpressured"); const bytesWhenPauseRequested = durableBytes; const pausing = transferBridge.pauseTransfer(null, { transferId: "local-pipe-pause" }); holdWrites = false; for (const callback of pendingWriteCallbacks.splice(0)) callback(); const paused = await pausing; assert.equal(paused.success, true, paused.reason); const checkpoint = paused.checkpointBytes; assert.ok(checkpoint >= bytesWhenPauseRequested); assert.ok( checkpoint < payload.length, "pause must stop before the copy finishes under backpressure", ); await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal( durableBytes, checkpoint, "paused stream copy must not keep writing after drain", ); { const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-pipe-pause" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } assert.equal((await running).error, undefined); assert.equal(durableBytes, payload.length); }); test("repeated resume does not double-pipe the same stream pair", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-double-resume-local-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const payload = Buffer.alloc(256 * 1024, 77); const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(sourcePath, payload); let durableBytes = 0; const pendingWriteCallbacks = []; let holdWrites = true; let pipeCount = 0; let activeWriteStream = null; const originalCreateWriteStream = fs.createWriteStream; t.after(() => { fs.createWriteStream = originalCreateWriteStream; }); fs.createWriteStream = (filePath, options) => { const real = originalCreateWriteStream(filePath, options); activeWriteStream = new Writable({ highWaterMark: 16, write(chunk, encoding, callback) { real.write(chunk, encoding, (err) => { if (err) return callback(err); durableBytes += chunk.length; activeWriteStream.bytesWritten = durableBytes; if (holdWrites) { pendingWriteCallbacks.push(callback); return; } callback(); }); }, final(callback) { real.end(callback); }, destroy(err, callback) { real.destroy(err); callback(err); }, }); activeWriteStream.bytesWritten = 0; return activeWriteStream; }; const originalReadablePipe = Readable.prototype.pipe; t.after(() => { Readable.prototype.pipe = originalReadablePipe; }); Readable.prototype.pipe = function patchedPipe(dest, ...args) { if (dest === activeWriteStream) pipeCount += 1; return originalReadablePipe.apply(this, [dest, ...args]); }; transferBridge.init({ sftpClients: new Map() }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId: "local-double-resume", sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: payload.length, resumable: true, }, ); const backpressureDeadline = Date.now() + 2000; while (pendingWriteCallbacks.length === 0 && Date.now() < backpressureDeadline) { await new Promise((resolve) => setImmediate(resolve)); } assert.ok(pendingWriteCallbacks.length > 0, "write stream should be backpressured"); const pausing = transferBridge.pauseTransfer(null, { transferId: "local-double-resume" }); holdWrites = false; for (const callback of pendingWriteCallbacks.splice(0)) callback(); const paused = await pausing; assert.equal(paused.success, true, paused.reason); const pipesAfterPause = pipeCount; { const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-double-resume" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } { const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-double-resume" }); assert.equal(resumed.success, true); assert.ok(Number.isFinite(resumed.lifecycleEpoch)); } assert.equal( pipeCount, pipesAfterPause + 1, "second resume must not call pipe() again on the same pair", ); assert.equal((await running).error, undefined); assert.equal(durableBytes, payload.length); }); test("resumable downloads never promote a partial staged file", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-partial-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(targetPath, Buffer.from("original")); // Remote pretends size=6 but EOF after 3 bytes — concurrent READ must fail closed // without promoting a partial stage over the existing target. const partial = Buffer.from("abc"); const { sftp } = createPipelinedDownloadSftp(partial, { read(_handle, buffer, offset, length, position, callback) { if (position >= partial.length) { setImmediate(() => callback(null, 0)); return; } const slice = partial.subarray(position, Math.min(position + length, partial.length)); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, }); const client = { sftp, stat() { return Promise.resolve({ size: 6 }); }, client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } }, }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const sender = createSender(); const running = transferBridge.startTransfer( { sender }, { transferId: "download-partial", sourcePath: "/tmp/source.bin", targetPath, sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 6, resumable: true, }, ); const result = await running; assert.match(result.error || "", /full source|size mismatch|pipelined download failed/i); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("old-style transfers explicitly reject pause", async () => { transferBridge.init({ sftpClients: new Map() }); assert.deepEqual( await transferBridge.pauseTransfer(null, { transferId: "missing" }), { success: false, reason: "Transfer is no longer active" }, ); }); test("server-to-server upload resume uses its own checkpoint instead of overall progress", async (t) => { const transferId = `server-copy-${crypto.randomUUID()}`; const sourcePath = "/source/payload.bin"; const targetPath = "/target/payload.bin"; const payload = Buffer.from("abcdef"); const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin"); await fs.promises.writeFile(localStage, payload); t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); }); let remote = Buffer.alloc(0); let promoted = false; const targetSftp = createFastSftp({ open(_path, flags, callback) { callback(null, Buffer.from("target-handle")); }, write(_handle, buffer, offset, length, position, callback) { const chunk = buffer.subarray(offset, offset + length); if (remote.length < position) { remote = Buffer.concat([remote, Buffer.alloc(position - remote.length)]); } remote = Buffer.concat([ remote.subarray(0, position), chunk, remote.subarray(position + chunk.length), ]); callback(null); }, close(_handle, callback) { callback(null); }, }); const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }) }; const targetClient = { __netcattySudoMode: true, sftp: targetSftp, stat: async () => ({ size: remote.length }), rename: async () => { promoted = true; }, delete: async () => {}, }; transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "sftp", targetType: "sftp", sourceSftpId: "source", targetSftpId: "target", totalBytes: payload.length, resumable: true, resumeStage: "upload", checkpointBytes: payload.length / 2, downloadCheckpointBytes: payload.length, uploadCheckpointBytes: 0, }); assert.equal(result.error, undefined, result.error); assert.deepEqual(remote, payload); assert.equal(promoted, true); }); test("server-to-server staged upload does not promote after AbortSignal-only cancellation", async (t) => { const transferId = `server-copy-signal-cancel-${crypto.randomUUID()}`; const sourcePath = "/source/payload.bin"; const targetPath = "/target/payload.bin"; const payload = Buffer.from("abcdef"); const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin"); await fs.promises.writeFile(localStage, payload); t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); }); const controller = new AbortController(); let remote = Buffer.alloc(0); let promoted = false; const removed = []; const targetSftp = createFastSftp({ open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); }, write(_handle, buffer, offset, length, position, callback) { const chunk = buffer.subarray(offset, offset + length); remote = Buffer.concat([ remote.subarray(0, position), chunk, remote.subarray(position + chunk.length), ]); callback(null); }, close(_handle, callback) { callback(null); controller.abort(); }, }); const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }) }; const targetClient = { __netcattySudoMode: true, sftp: targetSftp, stat: async () => ({ size: remote.length }), rename: async () => { promoted = true; }, delete: async (remotePath) => { removed.push(String(remotePath)); }, }; transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "sftp", targetType: "sftp", sourceSftpId: "source", targetSftpId: "target", totalBytes: payload.length, resumable: true, resumeStage: "upload", downloadCheckpointBytes: payload.length, uploadCheckpointBytes: 0, abortSignal: controller.signal, }); assert.match(result.error || "", /cancel/i); assert.equal(promoted, false); assert.ok(removed.some((remotePath) => remotePath.includes(".netcatty-"))); }); test("server-to-server SCP fallback uses the followed symlink content size", async (t) => { const transferId = `scp-symlink-s2s-${crypto.randomUUID()}`; const sourcePath = "/source/link"; const targetPath = "/target/copied.bin"; const payload = Buffer.from("followed-symlink-target-content"); const localStage = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath)); t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); }); const sourceBackend = { async stat() { return { type: "symlink", isSymbolicLink: true, size: 4 }; }, async downloadFile(_remotePath, localPath, options = {}) { await fs.promises.writeFile(localPath, payload); const halfway = Math.floor(payload.length / 2); options.onProgress?.(halfway, payload.length); options.onProgress?.(payload.length, payload.length); return { fileSize: payload.length, transferred: payload.length }; }, }; const remoteFiles = new Map([[targetPath, Buffer.from("old target")]]); let releaseFinalRename; let markFinalRenameStarted; const finalRenameStarted = new Promise((resolve) => { markFinalRenameStarted = resolve; }); const missing = () => { const error = new Error("No such file"); error.code = "ENOENT"; return error; }; const targetBackend = { async stat(remotePath) { if (!remoteFiles.has(remotePath)) throw missing(); return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length }; }, async mkdir() {}, async uploadFile(localPath, remotePath) { remoteFiles.set(remotePath, await fs.promises.readFile(localPath)); return true; }, async rename(from, to) { if (!remoteFiles.has(from)) throw missing(); if (from.includes(".netcatty-upload-") && to === targetPath) { markFinalRenameStarted(); await new Promise((resolve) => { releaseFinalRename = resolve; }); } remoteFiles.set(to, remoteFiles.get(from)); remoteFiles.delete(from); }, async remove(remotePath) { remoteFiles.delete(remotePath); }, async chmod() {}, }; const sourceClient = { __netcattyFileProtocol: "scp", __netcattyScpBackend: sourceBackend, }; const targetClient = { __netcattyFileProtocol: "scp", __netcattyScpBackend: targetBackend, }; transferBridge.init({ sftpClients: new Map([["source-scp", sourceClient], ["target-scp", targetClient]]), }); const sender = createSender(); const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath, targetPath, sourceType: "sftp", targetType: "sftp", sourceSftpId: "source-scp", targetSftpId: "target-scp", resumable: false, sameHost: false, }); await finalRenameStarted; await transferBridge.cancelTransfer(null, { transferId }); releaseFinalRename(); const result = await running; assert.equal(result.error, undefined, result.error); assert.deepEqual(remoteFiles.get(targetPath), payload); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false); assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true); const progress = sender.sent .filter((entry) => entry.channel === "netcatty:transfer:progress") .map((entry) => entry.payload); for (let index = 1; index < progress.length; index += 1) { assert.ok( progress[index].transferred >= progress[index - 1].transferred, `progress moved backwards: ${JSON.stringify(progress)}`, ); } for (const entry of progress.slice(0, -1)) { assert.ok( entry.totalBytes === 0 || entry.transferred < entry.totalBytes, `progress completed before the transfer: ${JSON.stringify(progress)}`, ); } assert.ok( progress.some((entry) => ( entry.totalBytes === payload.length && entry.transferred === Math.floor(payload.length / 2) )), `missing completed-download midpoint: ${JSON.stringify(progress)}`, ); }); test("server-to-server concurrent failure does not complete via serial stream", async (t) => { const transferId = `server-copy-fail-closed-${crypto.randomUUID()}`; const sourcePath = "/source/payload.bin"; const targetPath = "/target/payload.bin"; const payload = Buffer.alloc(3 * 32 * 1024, 53); const localStage = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath)); await fs.promises.writeFile(localStage, payload); t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); }); let secondWriteCallback = null; let thirdWriteCompleted = false; let secondWriteFailureScheduled = false; const scheduleSecondWriteFailure = () => { if (secondWriteFailureScheduled || !thirdWriteCompleted || !secondWriteCallback) return; secondWriteFailureScheduled = true; const callback = secondWriteCallback; secondWriteCallback = null; queueMicrotask(() => callback(new Error("second range failed"))); }; let remoteBytes = 0; let createWriteStreamCalls = 0; const fastSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(null, Buffer.from("remote-handle")); }, write(_handle, _buffer, _offset, length, position, callback) { if (position === 32 * 1024) { secondWriteCallback = callback; scheduleSecondWriteFailure(); return; } remoteBytes = Math.max(remoteBytes, position + length); callback(null); if (position === 2 * 32 * 1024) { thirdWriteCompleted = true; scheduleSecondWriteFailure(); } }, close(_handle, callback) { callback(null); }, end() {}, }); const targetSftp = createFastSftp({ open(_remotePath, _flags, callback) { callback(new Error("shared open also fails")); }, createWriteStream() { createWriteStreamCalls += 1; throw new Error("serial WriteStream must not run"); }, }); const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }), }; const targetClient = { sftp: targetSftp, stat: async () => ({ size: remoteBytes }), rename: async () => {}, delete: async () => {}, client: { sftp(callback) { callback(null, fastSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) }); const result = await transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "sftp", targetType: "sftp", sourceSftpId: "source", targetSftpId: "target", totalBytes: payload.length, resumable: true, resumeStage: "upload", downloadCheckpointBytes: payload.length, uploadCheckpointBytes: 0, }, ); assert.match(result.error || "", /pipelined upload failed|second range failed/i); assert.equal(createWriteStreamCalls, 0); // Local S2S temp (full download) must survive failed upload attempts. assert.equal(fs.statSync(localStage).size, payload.length); }); test("upload recovery without a full source identity restarts from zero", async (t) => { // Simulates force-quit mid-upload: UI progress saved a high checkpoint, but // only a shorter prefix made it into the remote staged .part file. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-crash-resume-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `crash-resume-${crypto.randomUUID()}`; const sourcePath = path.join(tempDir, "source.bin"); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2 + 19); for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; await fs.promises.writeFile(sourcePath, payload); // The durable prefix is deliberately not chunk-aligned. The first resumed // request must stop at the digest boundary, and the final bytes must match. let remote = Buffer.from(payload.subarray(0, 4)); let promoted = false; let minWritePosition = Infinity; const concurrentSftp = createFastSftp({ open(_path, flags, callback) { callback(null, Buffer.from("resume-handle")); }, write(_handle, buffer, offset, length, position, callback) { minWritePosition = Math.min(minWritePosition, position); const chunk = buffer.subarray(offset, offset + length); if (remote.length < position) { remote = Buffer.concat([remote, Buffer.alloc(position - remote.length)]); } remote = Buffer.concat([ remote.subarray(0, position), chunk, remote.subarray(position + chunk.length), ]); callback(null); }, close(_handle, callback) { callback(null); }, // Resume safety samples the durable remote prefix via createReadStream. createReadStream(_path, options = {}) { const start = options.start || 0; const end = options.end; const slice = end === undefined ? remote.subarray(start) : remote.subarray(start, end + 1); return Readable.from([slice]); }, createWriteStream() { throw new Error("serial WriteStream must not run"); }, }); const client = { __netcattySudoMode: true, // shared concurrent path only sftp: concurrentSftp, // First stats clamp checkpoint from claimed 8 down to durable remote size 4. stat: async () => ({ size: remote.length }), rename: async () => { promoted = true; }, delete: async () => {}, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath: "/root/source.bin", sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: true, checkpointBytes: 8, // ahead of durable remote size 4 }); assert.equal(result.error, undefined, result.error); assert.deepEqual(remote, payload); assert.equal(promoted, true); // A checkpoint without a full source digest cannot prove the source suffix // stayed unchanged across the crash, so the safe recovery is a full restart. assert.equal(minWritePosition, 0); }); test("local resume after hard quit clamps checkpoint to durable staged file size", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-local-crash-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `local-crash-${crypto.randomUUID()}`; const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); await fs.promises.writeFile(sourcePath, Buffer.from("abcdef")); // Durable staged file is shorter than claimed checkpoint (crash / unflushed write). await fs.promises.writeFile(stagedPath, Buffer.from("ab")); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); transferBridge.init({ sftpClients: new Map() }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: 6, resumable: true, checkpointBytes: 5, }); assert.equal(result.error, undefined, result.error); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "abcdef"); }); test("resume rejects a same-size temporary prefix that does not match the source", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const transferId = `prefix-${crypto.randomUUID()}`; const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin"); await fs.promises.writeFile(sourcePath, Buffer.from("abcdef")); await fs.promises.writeFile(stagedPath, Buffer.from("xyz")); t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); }); transferBridge.init({ sftpClients: new Map() }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId, sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: 6, resumable: true, checkpointBytes: 3, sourceFingerprint: `sha256:${crypto.createHash("sha256").update("abcdef").digest("hex")}`, }); assert.match(result.error || "", /saved content does not match/i); assert.equal(await fs.promises.readFile(sourcePath, "utf8"), "abcdef"); }); test("bridge admission applies one global concurrency limit across callers", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-admission-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const pendingByPath = new Map(); const payloads = { "/first": Buffer.from("a"), "/second": Buffer.from("b"), }; const sftp = createFastSftp({ open(remotePath, flags, callback) { const cb = typeof flags === "function" ? flags : callback; cb(null, Buffer.from(`handle:${remotePath}`)); }, read(handle, buffer, offset, length, position, callback) { const key = handle.toString().replace(/^handle:/, ""); const payload = payloads[key] || Buffer.from("x"); // Stall only the first READ per path so admission can observe "started". // Later verification samples complete immediately. if (!pendingByPath.has(key)) { pendingByPath.set(key, []); pendingByPath.get(key).push(() => { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); callback(null, slice.length); }); return; } const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, // Content-hash helpers only — bulk body uses open/read above. createReadStream(remotePath, options = {}) { const payload = payloads[remotePath] || Buffer.from("x"); const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : payload.length - 1; return Readable.from([Buffer.from(payload.subarray(start, end + 1))]); }, }); const client = { sftp, stat: async () => ({ size: 1 }) }; transferBridge.init({ sftpClients: new Map([["source", client]]) }); const start = (id, remotePath) => transferBridge.startTransfer({ sender: createSender() }, { transferId: id, sourcePath: remotePath, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 1, resumable: true, globalConcurrency: 1, }); const first = start("admission-first", "/first"); assert.equal( await waitUntil(() => (pendingByPath.get("/first") || []).length > 0), true, "first admitted transfer did not start", ); const second = start("admission-second", "/second"); await new Promise((resolve) => setImmediate(resolve)); assert.equal(pendingByPath.has("/second"), false); for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver(); assert.equal((await first).error, undefined); assert.equal( await waitUntil(() => (pendingByPath.get("/second") || []).length > 0), true, "second admitted transfer did not start after the first completed", ); for (const deliver of (pendingByPath.get("/second") || []).splice(0)) deliver(); assert.equal((await second).error, undefined); }); test("bridge admission gives different remote sessions independent concurrency", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-per-session-test-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const makeClient = (label) => { const pending = []; let firstRead = true; const payload = Buffer.from(label); const { sftp } = createPipelinedDownloadSftp(payload, { read(_handle, buffer, offset, length, position, callback) { const deliver = () => { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); callback(null, slice.length); }; if (firstRead) { firstRead = false; pending.push(deliver); return; } setImmediate(deliver); }, }); return { client: { sftp, stat: async () => ({ size: 1 }) }, pending, release() { for (const deliver of pending.splice(0)) deliver(); }, }; }; const a = makeClient("a"); const b = makeClient("b"); transferBridge.init({ sftpClients: new Map([ ["source-a", a.client], ["source-b", b.client], ]) }); const start = (id, sourceSftpId) => transferBridge.startTransfer({ sender: createSender() }, { transferId: id, sourcePath: `/${id}`, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId, totalBytes: 1, resumable: true, globalConcurrency: 1, }); const first = start("per-session-a", "source-a"); const second = start("per-session-b", "source-b"); const bothStarted = await waitUntil( () => a.pending.length > 0 && b.pending.length > 0, 500, ); a.release(); b.release(); await Promise.all([first, second]); assert.equal(bothStarted, true); }); test("clearPendingCancel allows intentional same-id start after a pre-start cancel", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-clear-pending-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const { sftp } = createPipelinedDownloadSftp(Buffer.from("a")); transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) }); await transferBridge.cancelTransfer(null, { transferId: "retry-same-id" }); transferBridge.clearPendingCancel("retry-same-id"); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "retry-same-id", sourcePath: "/remote", targetPath: path.join(tempDir, "out.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 1, skipAdmission: true, }); assert.equal(result.cancelled, undefined); assert.equal(result.error, undefined, result.error); }); test("cancel before skipAdmission start rejects the transfer without writing", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pending-cancel-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); let openCalls = 0; const { sftp } = createPipelinedDownloadSftp(Buffer.from("a"), { open(_remotePath, flags, callback) { openCalls += 1; const cb = typeof flags === "function" ? flags : callback; cb(null, Buffer.from("read-handle")); }, }); transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) }); await transferBridge.cancelTransfer(null, { transferId: "pending-cancel-1" }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "pending-cancel-1", sourcePath: "/remote", targetPath: path.join(tempDir, "out.bin"), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 1, skipAdmission: true, }); assert.equal(result.cancelled, true); assert.equal(openCalls, 0, "pre-start cancel must not open a remote handle"); }); test("pre-start cancel latches stay hard bounded when cancelled work never starts", async (t) => { t.after(() => { for (let index = 0; index < 5_000; index += 1) { transferBridge.clearPendingCancel(`orphan-cancel-${index}`); } }); for (let index = 0; index < 5_000; index += 1) { await transferBridge.cancelTransfer(null, { transferId: `orphan-cancel-${index}` }); } assert.ok( transferBridge._getPendingCancelCountForTests() <= 4_096, `pending cancel latches grew to ${transferBridge._getPendingCancelCountForTests()}`, ); }); test("pausing a queued admission job preserves the payload checkpoint", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-queued-checkpoint-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const pendingByPath = new Map(); const payloads = { "/first": Buffer.from("a"), "/second": Buffer.from("b") }; const sftp = createFastSftp({ open(remotePath, flags, callback) { const cb = typeof flags === "function" ? flags : callback; cb(null, Buffer.from(`handle:${remotePath}`)); }, read(handle, buffer, offset, length, position, callback) { const key = handle.toString().replace(/^handle:/, ""); const payload = payloads[key] || Buffer.from("x"); if (!pendingByPath.has(key)) { pendingByPath.set(key, []); pendingByPath.get(key).push(() => { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); callback(null, slice.length); }); return; } const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream(remotePath, options = {}) { const payload = payloads[remotePath] || Buffer.from("x"); const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : payload.length - 1; return Readable.from([Buffer.from(payload.subarray(start, end + 1))]); }, }); transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) }); const start = (id, remotePath, checkpointBytes) => transferBridge.startTransfer({ sender: createSender() }, { transferId: id, sourcePath: remotePath, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 1, resumable: true, checkpointBytes, globalConcurrency: 1, }); const first = start("queued-ckpt-first", "/first", 0); assert.ok(await waitUntil(() => (pendingByPath.get("/first") || []).length > 0)); const second = start("queued-ckpt-second", "/second", 42); const paused = await transferBridge.pauseTransfer(null, { transferId: "queued-ckpt-second" }); assert.equal(paused.success, true); assert.equal(paused.checkpointBytes, 42); assert.equal((await transferBridge.cancelTransfer(null, { transferId: "queued-ckpt-second" })).success, true); assert.equal((await second).cancelled, true); for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver(); assert.equal((await first).error, undefined); }); test("queued admission jobs can be paused, resumed, prioritized, and cancelled before opening a stream", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-queued-controls-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const pendingByPath = new Map(); let secondOpenCalls = 0; const payloads = { "/first": Buffer.from("a"), "/second": Buffer.from("b") }; const sftp = createFastSftp({ open(remotePath, flags, callback) { const cb = typeof flags === "function" ? flags : callback; if (String(remotePath).includes("second")) secondOpenCalls += 1; cb(null, Buffer.from(`handle:${remotePath}`)); }, read(handle, buffer, offset, length, position, callback) { const key = handle.toString().replace(/^handle:/, ""); const payload = payloads[key] || Buffer.from("x"); if (!pendingByPath.has(key)) { pendingByPath.set(key, []); pendingByPath.get(key).push(() => { const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); callback(null, slice.length); }); return; } const slice = payload.subarray(position, position + length); slice.copy(buffer, offset); setImmediate(() => callback(null, slice.length)); }, close(_handle, callback) { callback(null); }, createReadStream(remotePath, options = {}) { const payload = payloads[remotePath] || Buffer.from("x"); const start = Number.isFinite(options.start) ? options.start : 0; const end = Number.isFinite(options.end) ? options.end : payload.length - 1; return Readable.from([Buffer.from(payload.subarray(start, end + 1))]); }, }); transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) }); const start = (id, remotePath) => transferBridge.startTransfer({ sender: createSender() }, { transferId: id, sourcePath: remotePath, targetPath: path.join(tempDir, `${id}.bin`), sourceType: "sftp", targetType: "local", sourceSftpId: "source", totalBytes: 1, resumable: true, globalConcurrency: 1, }); const first = start("queued-control-first", "/first"); assert.ok(await waitUntil(() => (pendingByPath.get("/first") || []).length > 0)); const second = start("queued-control-second", "/second"); assert.equal((await transferBridge.pauseTransfer(null, { transferId: "queued-control-second" })).success, true); assert.equal(secondOpenCalls, 0, "queued second must not open while first holds admission"); assert.equal((await transferBridge.resumeTransfer(null, { transferId: "queued-control-second" })).success, true); assert.equal((await transferBridge.prioritizeTransfer(null, { transferId: "queued-control-second" })).success, true); assert.equal((await transferBridge.cancelTransfer(null, { transferId: "queued-control-second" })).success, true); assert.equal((await second).cancelled, true); for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver(); assert.equal((await first).error, undefined); assert.equal(secondOpenCalls, 0, "cancelled queued job must never open a remote handle"); }); test("transfer session leases hold SFTP ids across soft-close until release", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); sftpTransferSessionLeaseStore.resetForTests(); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); let hardCloseCalls = 0; const sftpBridge = require("./sftpBridge.cjs"); const originalClose = sftpBridge.closeSftp; sftpBridge.closeSftp = async (_event, payload) => { if (payload?.force) { hardCloseCalls += 1; sftpTransferSessionLeaseStore.clear(payload.sftpId); return { success: true, deferred: false }; } if (sftpTransferSessionLeaseStore.markSoftClosed(payload.sftpId)) { return { success: true, deferred: true, leaseCount: sftpTransferSessionLeaseStore.getLeaseCount(payload.sftpId), }; } return { success: true, deferred: false }; }; t.after(() => { sftpBridge.closeSftp = originalClose; }); assert.deepEqual( transferBridge.listTransferSftpIds({ sourceSftpId: "s1", targetSftpId: "s2", sourceHostId: "h" }), ["s1", "s2"], ); // Hold two transfers on s1 before soft-close (re-acquire after soft-close // would clear the deferred flag by design). transferBridge.acquireTransferSessionLeases("xfer-1", { sourceSftpId: "s1", targetSftpId: "s2", }); transferBridge.acquireTransferSessionLeases("xfer-2", { sourceSftpId: "s1" }); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount("s1"), 2); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount("s2"), 1); const soft = await sftpBridge.closeSftp(null, { sftpId: "s1" }); assert.equal(soft.deferred, true); assert.equal(sftpTransferSessionLeaseStore.isSoftClosed("s1"), true); assert.equal(hardCloseCalls, 0); transferBridge.releaseTransferSessionLeases("xfer-1", ["s1", "s2"]); assert.equal(hardCloseCalls, 0); assert.equal(sftpTransferSessionLeaseStore.isHeld("s1"), true); assert.equal(sftpTransferSessionLeaseStore.isHeld("s2"), false); // Last release on soft-closed session triggers hard close. transferBridge.releaseTransferSessionLeases("xfer-2", ["s1"]); await new Promise((resolve) => setImmediate(resolve)); assert.equal(hardCloseCalls, 1); assert.equal(sftpTransferSessionLeaseStore.isHeld("s1"), false); }); test("a directory pool lease survives terminal close between child files", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); sftpTransferSessionLeaseStore.resetForTests(); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); let hardCloseCalls = 0; const sftpBridge = require("./sftpBridge.cjs"); const originalClose = sftpBridge.closeSftp; sftpBridge.closeSftp = async (_event, payload) => { if (payload?.force) { hardCloseCalls += 1; sftpTransferSessionLeaseStore.clear(payload.sftpId); return { success: true, deferred: false }; } if (sftpTransferSessionLeaseStore.markSoftClosed(payload.sftpId)) { return { success: true, deferred: true, leaseCount: sftpTransferSessionLeaseStore.getLeaseCount(payload.sftpId), }; } return { success: true, deferred: false }; }; t.after(() => { sftpBridge.closeSftp = originalClose; }); transferBridge.init({ sftpClients: new Map([["folder-sftp", {}]]) }); assert.deepEqual( transferBridge.retainSftpTransferSession(null, { sftpId: "folder-sftp", leaseId: "pool:folder-sftp", }), { success: true }, ); transferBridge.acquireTransferSessionLeases("child-1", { targetSftpId: "folder-sftp" }); const soft = await sftpBridge.closeSftp(null, { sftpId: "folder-sftp" }); assert.equal(soft.deferred, true); transferBridge.releaseTransferSessionLeases("child-1", ["folder-sftp"]); await new Promise((resolve) => setImmediate(resolve)); assert.equal(hardCloseCalls, 0, "the directory pool must keep the session alive between files"); assert.doesNotThrow(() => { transferBridge.acquireTransferSessionLeases("child-2", { targetSftpId: "folder-sftp" }); }); transferBridge.releaseTransferSessionLeases("child-2", ["folder-sftp"]); assert.deepEqual( transferBridge.releaseSftpTransferSession(null, { sftpId: "folder-sftp", leaseId: "pool:folder-sftp", }), { success: true }, ); await new Promise((resolve) => setImmediate(resolve)); assert.equal(hardCloseCalls, 1, "the deferred close should run only after the pool releases"); }); test("directory child lifecycle events preserve their parent id", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-child-event-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const sourcePath = path.join(tempDir, "child.txt"); const targetPath = path.join(tempDir, "copied.txt"); await fs.promises.writeFile(sourcePath, "child"); transferBridge.init({ sftpClients: new Map() }); const sender = createSender(); const result = await transferBridge.startTransfer({ sender }, { transferId: "folder-child-event", parentTaskId: "folder-root-event", sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: 5, resumable: false, skipAdmission: true, }); assert.equal(result.error, undefined); const started = sender.sent.find((entry) => entry.channel === "netcatty:transfer:started"); assert.equal(started?.payload?.parentTaskId, "folder-root-event"); }); test("a committed soft-close never lends the SFTP client again while client.end is pending", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); const sftpBridge = require("./sftpBridge.cjs"); sftpTransferSessionLeaseStore.resetForTests(); let releaseEnd; const endGate = new Promise((resolve) => { releaseEnd = resolve; }); let markEndStarted; const endStarted = new Promise((resolve) => { markEndStarted = resolve; }); let endCalls = 0; const client = { async end() { endCalls += 1; markEndStarted(); await endGate; }, }; const sftpClients = new Map([["sftp-soft-close-race", client]]); transferBridge.init({ sftpClients }); sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} }); t.after(() => { releaseEnd(); sftpTransferSessionLeaseStore.resetForTests(); sftpClients.clear(); }); transferBridge.acquireTransferSessionLeases("directory-child-1", { sourceSftpId: "sftp-soft-close-race", }); const softClose = await sftpBridge.closeSftp(null, { sftpId: "sftp-soft-close-race" }); assert.equal(softClose.deferred, true); transferBridge.releaseTransferSessionLeases("directory-child-1", ["sftp-soft-close-race"]); await endStarted; assert.throws( () => transferBridge.acquireTransferSessionLeases("directory-child-2", { sourceSftpId: "sftp-soft-close-race", }), /closing|closed/i, ); assert.equal(sftpTransferSessionLeaseStore.isHeld("sftp-soft-close-race"), false); releaseEnd(); const deadline = Date.now() + 1_000; while (sftpClients.has("sftp-soft-close-race")) { if (Date.now() >= deadline) throw new Error("Timed out waiting for committed SFTP close"); await new Promise((resolve) => setImmediate(resolve)); } assert.equal(endCalls, 1); }); test("a direct panel close never lends the SFTP client while client.end is pending", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); const sftpBridge = require("./sftpBridge.cjs"); sftpTransferSessionLeaseStore.resetForTests(); let releaseEnd; const endGate = new Promise((resolve) => { releaseEnd = resolve; }); let markEndStarted; const endStarted = new Promise((resolve) => { markEndStarted = resolve; }); const client = { async end() { markEndStarted(); await endGate; }, }; const sftpClients = new Map([["sftp-direct-close-race", client]]); transferBridge.init({ sftpClients }); sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} }); t.after(() => { releaseEnd(); sftpTransferSessionLeaseStore.resetForTests(); sftpClients.clear(); }); const closePromise = sftpBridge.closeSftp(null, { sftpId: "sftp-direct-close-race" }); await endStarted; assert.throws( () => transferBridge.acquireTransferSessionLeases("new-transfer", { sourceSftpId: "sftp-direct-close-race", }), /closing|closed/i, ); releaseEnd(); const closeResult = await closePromise; assert.equal(closeResult.deferred, false); assert.equal(sftpClients.has("sftp-direct-close-race"), false); assert.equal(sftpTransferSessionLeaseStore.isHeld("sftp-direct-close-race"), false); }); test("a transfer rejected by a committed SFTP close leaves no active transfer entry", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); const sftpId = "sftp-rejected-before-lease"; const closeToken = sftpTransferSessionLeaseStore.beginHardClose(sftpId); assert.equal(sftpTransferSessionLeaseStore.commitHardClose(sftpId, closeToken), true); transferBridge.init({ sftpClients: new Map([[sftpId, {}]]) }); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); await assert.rejects( transferBridge.startTransfer({ sender: createSender() }, { transferId: "rejected-before-lease", sourcePath: "/remote/file.bin", targetPath: "/tmp/rejected-before-lease.bin", sourceType: "sftp", targetType: "local", sourceSftpId: sftpId, totalBytes: 1, skipAdmission: true, }), /closing|closed/i, ); assert.equal(transferBridge._getActiveTransferCountForTests(), 0); }); test("same-host directory copy rejected by a committed close leaves no lease or active transfer", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); const sftpId = "same-host-directory-closing"; sftpTransferSessionLeaseStore.resetForTests(); const closeToken = sftpTransferSessionLeaseStore.beginHardClose(sftpId); assert.equal(sftpTransferSessionLeaseStore.commitHardClose(sftpId, closeToken), true); transferBridge.init({ sftpClients: new Map([[sftpId, {}]]) }); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); await assert.rejects( transferBridge.sameHostCopyDirectory( { sender: createSender() }, { transferId: "same-host-directory-rejected", sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8", }, ), /closing|closed/i, ); assert.equal(transferBridge._getActiveTransferCountForTests(), 0); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0); }); test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); sftpTransferSessionLeaseStore.resetForTests(); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); const sftpId = "same-host-directory-cancel"; let markExecOpened; const execOpened = new Promise((resolve) => { markExecOpened = resolve; }); const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.close = () => {}; stream.end = () => {}; stream.destroy = () => {}; const client = { client: { exec(_command, callback) { callback(null, stream); markExecOpened(); }, }, }; transferBridge.init({ sftpClients: new Map([[sftpId, client]]) }); const running = transferBridge.sameHostCopyDirectory( { sender: createSender() }, { transferId: "same-host-directory-cancelled", sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8", }, ); await execOpened; assert.equal(transferBridge._getActiveTransferCountForTests(), 1); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 1); await transferBridge.cancelTransfer(null, { transferId: "same-host-directory-cancelled" }); await assert.rejects(running, /cancel/i); assert.equal(transferBridge._getActiveTransferCountForTests(), 0); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0); }); test("a pre-start cancellation prevents same-host directory registration and lease acquisition", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); sftpTransferSessionLeaseStore.resetForTests(); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); const sftpId = "same-host-directory-pre-cancel"; let execCalls = 0; transferBridge.init({ sftpClients: new Map([[sftpId, { client: { exec() { execCalls += 1; }, }, }]]), }); const transferId = "same-host-directory-pre-cancelled"; await transferBridge.cancelTransfer(null, { transferId }); const result = await transferBridge.sameHostCopyDirectory( { sender: createSender() }, { transferId, sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8", }, ); assert.equal(result.cancelled, true); assert.equal(execCalls, 0); assert.equal(transferBridge._getActiveTransferCountForTests(), 0); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0); assert.equal(transferBridge._getPendingCancelCountForTests(), 0); }); test("same-host file cp cancellation releases the shared session lease", async (t) => { const { sftpTransferSessionLeaseStore, } = require("./sftpTransferSessionLease.cjs"); sftpTransferSessionLeaseStore.resetForTests(); t.after(() => sftpTransferSessionLeaseStore.resetForTests()); const sftpId = "same-host-file-cancel"; let markExecOpened; const execOpened = new Promise((resolve) => { markExecOpened = resolve; }); const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.close = () => {}; stream.end = () => {}; stream.destroy = () => {}; const client = { client: { exec(_command, callback) { callback(null, stream); markExecOpened(); }, }, }; transferBridge.init({ sftpClients: new Map([[sftpId, client]]) }); const transferId = "same-host-file-cancelled"; const running = transferBridge.startTransfer( { sender: createSender() }, { transferId, sourcePath: "/source/file.bin", targetPath: "/target/file.bin", sourceType: "sftp", targetType: "sftp", sourceSftpId: sftpId, targetSftpId: sftpId, totalBytes: 1, sameHost: true, skipAdmission: true, }, ); await execOpened; assert.equal(transferBridge._getActiveTransferCountForTests(), 1); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 1); await transferBridge.cancelTransfer(null, { transferId }); const result = await running; assert.equal(result.error, "Transfer cancelled"); assert.equal(transferBridge._getActiveTransferCountForTests(), 0); assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0); }); test("local promotion restores concurrent replacement moved into the backup", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-backup-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const stagedPath = path.join(tempDir, "download.staged"); const targetPath = path.join(tempDir, "target.bin"); const original = Buffer.from("original-target-content"); const replacement = Buffer.from("concurrent-replacement"); const downloaded = Buffer.from("downloaded-replacement"); await fs.promises.writeFile(targetPath, original); await fs.promises.writeFile(stagedPath, downloaded); const originalStat = await fs.promises.lstat(targetPath); const expectedStable = transferBridge._stableLocalFileIdentityForTests(originalStat); // validateTarget claims the original identity, but a concurrent process has // already replaced the live path. rename then moves the wrong file to backup. await assert.rejects( () => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { async validateTarget() { await fs.promises.unlink(targetPath); await fs.promises.writeFile(targetPath, replacement); return { // Keep existingMode null so promotion does not re-validate after chmod. existingMode: null, stableIdentity: expectedStable, targetIdentity: [ originalStat.dev, originalStat.ino, originalStat.size, originalStat.mtimeMs, originalStat.ctimeMs, ].join(":"), }; }, }), /changed during replacement/i, ); // Replacement must still be at the target (restored from backup), download not published. assert.deepEqual(await fs.promises.readFile(targetPath), replacement); assert.equal( (await fs.promises.readdir(tempDir)).some((name) => name.includes(".backup")), false, ); }); test("local promotion does not restore mismatched backup over a recreated target", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-mismatch-recreate-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const stagedPath = path.join(tempDir, "download.staged"); const targetPath = path.join(tempDir, "target.bin"); const original = Buffer.from("original-target-content"); const concurrent = Buffer.from("recreated-during-mismatch"); const downloaded = Buffer.from("downloaded-replacement"); await fs.promises.writeFile(targetPath, original); await fs.promises.writeFile(stagedPath, downloaded); // Claim a stable identity that will not match whatever lands in backup. const fakeStable = "999:999:999"; const originalRename = fs.promises.rename.bind(fs.promises); let injected = false; fs.promises.rename = async (from, to) => { const result = await originalRename(from, to); if (!injected && path.resolve(from) === path.resolve(targetPath) && String(to).includes(".backup")) { injected = true; // Recreate target after backup move; mismatch branch must not restore over it. await fs.promises.writeFile(targetPath, concurrent); } return result; }; t.after(() => { fs.promises.rename = originalRename; }); await assert.rejects( () => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { async validateTarget() { return { existingMode: null, stableIdentity: fakeStable, targetIdentity: `${fakeStable}:0:0`, }; }, }), /changed during replacement/i, ); assert.deepEqual(await fs.promises.readFile(targetPath), concurrent); const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup")); assert.ok(backupName); assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original); }); test("local promotion does not clobber a target recreated after backup", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-recreate-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const stagedPath = path.join(tempDir, "download.staged"); const targetPath = path.join(tempDir, "target.bin"); const original = Buffer.from("original-target-content"); const concurrent = Buffer.from("recreated-after-backup"); const downloaded = Buffer.from("downloaded-replacement"); await fs.promises.writeFile(targetPath, original); await fs.promises.writeFile(stagedPath, downloaded); const originalStat = await fs.promises.lstat(targetPath); const originalRename = fs.promises.rename.bind(fs.promises); let injected = false; fs.promises.rename = async (from, to) => { const result = await originalRename(from, to); if (!injected && path.resolve(from) === path.resolve(targetPath) && String(to).includes(".backup")) { injected = true; await fs.promises.writeFile(targetPath, concurrent); } return result; }; t.after(() => { fs.promises.rename = originalRename; }); await assert.rejects( () => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { async validateTarget() { return { existingMode: null, stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat), targetIdentity: [ originalStat.dev, originalStat.ino, originalStat.size, originalStat.mtimeMs, originalStat.ctimeMs, ].join(":"), }; }, }), /changed during replacement/i, ); assert.deepEqual(await fs.promises.readFile(targetPath), concurrent); // Original preserved in a backup artifact for recovery. const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup")); assert.ok(backupName); assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original); }); test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => { const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const stagedPath = path.join(tempDir, "download.staged"); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(targetPath, "original"); await fs.promises.writeFile(stagedPath, "download"); let cancelled = false; let committed = false; const link = fs.promises.link; t.after(() => { fs.promises.link = link; }); fs.promises.link = async (...args) => { await link.apply(fs.promises, args); if (args[1] === targetPath && String(args[0]).endsWith(".ready")) { await fs.promises.unlink(targetPath); await fs.promises.writeFile(targetPath, "concurrent"); cancelled = true; } }; await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); }, onCommit() { committed = true; }, }); assert.equal(committed, true); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent"); }); test("local promotion succeeds when backup still matches validated identity", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-ok-")); t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); const stagedPath = path.join(tempDir, "download.staged"); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(targetPath, Buffer.from("old")); await fs.promises.writeFile(stagedPath, Buffer.from("new")); const originalStat = await fs.promises.lstat(targetPath); await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { async validateTarget() { const latest = await fs.promises.lstat(targetPath); return { existingMode: latest.mode & 0o7777, stableIdentity: transferBridge._stableLocalFileIdentityForTests(latest), targetIdentity: [ latest.dev, latest.ino, latest.size, latest.mtimeMs, latest.ctimeMs, ].join(":"), }; }, }); assert.deepEqual(await fs.promises.readFile(targetPath), Buffer.from("new")); assert.notEqual( transferBridge._stableLocalFileIdentityForTests(await fs.promises.lstat(targetPath)), transferBridge._stableLocalFileIdentityForTests(originalStat), ); }); test("preserveTransferredDestinationMtime stamps local targets from sourceSoftIdentity", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const targetPath = path.join(tempDir, "copied.bin"); await fs.promises.writeFile(targetPath, Buffer.from("payload")); const before = await fs.promises.stat(targetPath); const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs }, }); const after = await fs.promises.stat(targetPath); assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000)); assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000)); }); test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const targetPath = path.join(tempDir, "copied.bin"); await fs.promises.writeFile(targetPath, Buffer.from("concurrent")); const before = await fs.promises.stat(targetPath); const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z // Identity recorded at publication no longer matches the pathname's file. await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, publishedLocalIdentity: "999999:999999:7", sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs }, }); const after = await fs.promises.stat(targetPath); assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000)); // A vanished destination is likewise left alone. await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath: path.join(tempDir, "missing.bin"), publishedLocalIdentity: "999999:999999:7", sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs }, }); }); test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const targetPath = path.join(tempDir, "copied.bin"); await fs.promises.writeFile(targetPath, Buffer.from("payload")); const publishedStat = await fs.promises.lstat(targetPath); const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat), sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs }, }); const after = await fs.promises.stat(targetPath); assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000)); }); test("local publication returns an identity usable for timestamp preservation", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-")); t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true })); const stagedPath = path.join(tempDir, "stage"); const targetPath = path.join(tempDir, "target"); await fs.promises.writeFile(stagedPath, "payload"); let publishedLocalIdentity; await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, { onCommit(identity) { publishedLocalIdentity = identity; }, }); await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, publishedLocalIdentity, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 }, }); assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000); }); test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-")); t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true })); const targetPath = path.join(tempDir, "target"); const oldPath = path.join(tempDir, "published"); await fs.promises.writeFile(targetPath, "payload"); const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath)); let replaced = false; const replace = async () => { if (replaced) return; replaced = true; await fs.promises.rename(targetPath, oldPath); await fs.promises.writeFile(targetPath, "concurrent"); await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000); }; const originalUtimes = fs.promises.utimes; const originalOpen = fs.promises.open; t.mock.method(fs.promises, "utimes", async (name, ...args) => { if (name === targetPath) await replace(); return originalUtimes(name, ...args); }); t.mock.method(fs.promises, "open", async (...args) => { const handle = await originalOpen(...args); if (args[0] === targetPath) { const utimes = handle.utimes.bind(handle); handle.utimes = async (...times) => { await replace(); return utimes(...times); }; } return handle; }); await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, publishedLocalIdentity, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 }, }); assert.equal(replaced, true); assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000); }); test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => { const hangingClient = { async chmod() { await new Promise(() => {}); }, }; const warnings = []; const originalWarn = console.warn; console.warn = (...args) => { warnings.push(args.map(String).join(" ")); }; const startedAt = Date.now(); try { await transferBridge._restoreRemoteUploadModeBestEffortForTests( hangingClient, "target", "/usr/local/bin/tool", "utf-8", 0o755, { timeoutMs: 40 }, ); } finally { console.warn = originalWarn; } const elapsed = Date.now() - startedAt; assert.ok(elapsed < 1500, `expected bounded chmod, elapsed=${elapsed}`); assert.ok( warnings.some((message) => /Failed to restore permissions|timed out/i.test(message)), `expected chmod timeout warning, got ${JSON.stringify(warnings)}`, ); }); test("preserveTransferredDestinationMtime times out hanging remote setStat", async () => { let setStatStarted = false; const hangingClient = { sftp: { readdir() {}, stat() {}, mkdir() {}, unlink() {}, }, async setStat() { setStatStarted = true; await new Promise(() => {}); }, }; transferBridge.init({ sftpClients: new Map([["target", hangingClient]]) }); const startedAt = Date.now(); await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "sftp", targetSftpId: "target", targetPath: "/tmp/mtime-hang.bin", sourceSoftIdentity: { size: 1, mtimeMs: 1_700_000_000_000 }, }, { timeoutMs: 40 }); const elapsed = Date.now() - startedAt; assert.equal(setStatStarted, true); assert.ok(elapsed < 1500, `expected bounded mtime stamp, elapsed=${elapsed}`); }); test("local-to-local transfer preserves source mtime on the destination", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-mtime-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const sourcePath = path.join(tempDir, "source.bin"); const targetPath = path.join(tempDir, "target.bin"); await fs.promises.writeFile(sourcePath, Buffer.from("hello-mtime")); const sourceMtimeMs = 1_600_000_000_000; // 2020-09-13T12:26:40.000Z await fs.promises.utimes(sourcePath, new Date(sourceMtimeMs), new Date(sourceMtimeMs)); transferBridge.init({ sftpClients: new Map() }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "local-mtime-preserve", sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: 11, resumable: false, }); assert.equal(result.error, undefined); const targetStat = await fs.promises.stat(targetPath); assert.equal(Math.floor(targetStat.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000)); assert.deepEqual(await fs.promises.readFile(targetPath), Buffer.from("hello-mtime")); }); test("waitForPendingWriteOpenPathGate timeout fails closed without clearing poison", async () => { let resolveGate; const gate = new Promise((resolve) => { resolveGate = resolve; }); let failCalls = 0; const transfer = { pendingWriteOpenPathGate: gate, _resolvePendingWriteOpenPathGate() { resolveGate(); transfer.pendingWriteOpenPathGate = null; transfer._resolvePendingWriteOpenPathGate = null; }, _failPendingWriteOpenPathGate() { failCalls += 1; }, }; await assert.rejects( () => transferBridge._waitForPendingWriteOpenPathGateForTests(transfer, { timeoutMs: 30 }), /Timed out waiting for prior write OPEN to settle/i, ); assert.equal(transfer.pendingWriteOpenPathGate, gate, "timeout must not clear the published gate poison"); assert.equal(failCalls, 1, "timeout must poison the shared path gate for later waiters"); resolveGate(); }); test("in-place isolated OPEN channel error fails closed without waiting on shared fallback", async (t) => { // Codex P1 on 3d4cecfa: keeping in-place poison without force-release must not // fall through into concurrent-shared, which waits forever on the prior gate. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-open-terminal-poison-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 73); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/inplace-terminal-poison.bin"; const remoteFiles = new Map([[targetPath, Buffer.from("keep-original")]]); let openCalls = 0; let sharedOpenCalls = 0; const isolatedSftp = createFastSftp({ open(_remotePath, flags, _callback) { assert.equal(flags, "w"); openCalls += 1; // Never invoke OPEN callback (dead channel after error). }, write() { throw new Error("WRITE must not run while OPEN is pending"); }, end() {}, fastPut() { throw new Error("fastPut must not run after in-place OPEN poison"); }, }); const sharedSftp = createFastSftp({ open() { sharedOpenCalls += 1; throw new Error("shared OPEN must not run after terminal in-place poison"); }, createWriteStream() { throw new Error("serial WriteStream must not run"); }, lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }, }); const client = { sftp: sharedSftp, async lstat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }; }, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete() {}, client: { sftp(callback) { callback(null, isolatedSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const running = transferBridge.startTransfer({ sender }, { transferId: "inplace-open-terminal-poison", sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }); const opened = await waitUntil(() => openCalls >= 1, 2000); assert.ok(opened, "expected isolated in-place OPEN to stall"); isolatedSftp.emit("error", new Error("isolated channel died during in-place OPEN")); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout( () => reject(new Error("transfer hung behind permanent in-place OPEN poison")), 4000, )), ]); assert.ok(result.error, "expected fail-closed transfer"); assert.match(result.error, /isolated channel died|pipelined upload failed/i); assert.equal(sharedOpenCalls, 0, "concurrent-shared must not wait on poisoned in-place gate"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); }); test("in-place isolated OPEN keeps path gate until late callback after channel error", async (t) => { // Codex P1 on e2cc8241: force-releasing an in-place truncating OPEN lets // fastPut complete, then a late OPEN truncates the reported-success file. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-open-no-force-release-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 71); const localPath = path.join(tempDir, "upload.bin"); await fs.promises.writeFile(localPath, payload); const targetPath = "/tmp/inplace-no-force.bin"; const existingPayload = Buffer.from("keep-original"); const remoteFiles = new Map([[targetPath, Buffer.from(existingPayload)]]); const eventLog = []; let releaseOpen = null; let openCalls = 0; let fastPutCalls = 0; const isolatedSftp = createFastSftp({ open(remotePath, flags, callback) { assert.equal(flags, "w"); openCalls += 1; const key = String(remotePath); releaseOpen = () => { remoteFiles.set(key, Buffer.alloc(0)); eventLog.push(`late-open-truncate:${key}`); callback(null, Buffer.from(`handle:${key}`)); }; }, write() { throw new Error("WRITE must not run while OPEN is pending"); }, close(_handle, callback) { eventLog.push("close"); callback(null); }, end() { eventLog.push("isolated-end"); }, fastPut() { fastPutCalls += 1; throw new Error("fastPut must not run while in-place OPEN poison is held"); }, }); const sharedSftp = createFastSftp({ open() { throw new Error("shared OPEN must not run while isolated in-place gate is held"); }, createWriteStream() { throw new Error("serial WriteStream must not run"); }, lstat(remotePath, callback) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; callback(error); return; } callback(null, { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }); }, }); const client = { sftp: sharedSftp, async lstat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length, mode: 0o120777, isDirectory: () => false, isSymbolicLink: () => true, }; }, async stat(remotePath) { const key = String(remotePath); if (!remoteFiles.has(key)) { const error = new Error("ENOENT"); error.code = 2; throw error; } return { size: remoteFiles.get(key).length }; }, rename() { return Promise.resolve(); }, async delete() {}, client: { sftp(callback) { callback(null, isolatedSftp); }, }, }; transferBridge.init({ sftpClients: new Map([["target", client]]) }); const sender = createSender(); const transferId = "inplace-open-no-force-release"; const running = transferBridge.startTransfer({ sender }, { transferId, sourcePath: localPath, targetPath, sourceType: "local", targetType: "sftp", targetSftpId: "target", totalBytes: payload.length, resumable: false, skipAdmission: true, }); const opened = await waitUntil(() => openCalls >= 1, 2000); assert.ok(opened, "expected isolated in-place OPEN to stall"); isolatedSftp.emit("error", new Error("isolated channel died during in-place OPEN")); // Former force-release window (2s). Path gate must still block fallbacks. await new Promise((resolve) => setTimeout(resolve, 2500)); assert.equal(fastPutCalls, 0, "fastPut must not run while in-place OPEN is unsettled"); assert.deepEqual(remoteFiles.get(targetPath), existingPayload, "destination must stay intact before late OPEN"); assert.equal(typeof releaseOpen, "function"); releaseOpen(); const result = await Promise.race([ running, new Promise((_, reject) => setTimeout(() => reject(new Error("transfer hung after late in-place OPEN")), 8000)), ]); assert.ok(result.error, "expected transfer to fail closed rather than report success after late truncate"); assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false); assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close")); }); test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-")); t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true })); const targetPath = path.join(tempDir, "copied.bin"); await fs.promises.writeFile(targetPath, Buffer.from("payload")); await fs.promises.chmod(targetPath, 0o200); const sourceMtimeMs = 1_700_000_000_000; await transferBridge._preserveTransferredDestinationMtimeForTests({ targetType: "local", targetPath, sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs }, }); const after = await fs.promises.stat(targetPath); assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000); assert.equal(after.mode & 0o777, 0o200); }); for (const failureMode of ["rejection", "timeout"]) { test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => { const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-")); t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true })); const sourcePath = path.join(tempDir, "source"); const targetPath = path.join(tempDir, "target"); await fs.promises.writeFile(sourcePath, "payload"); await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000); let preparationFailed = false; const utimes = fs.promises.utimes; t.mock.method(fs.promises, "utimes", async (name, ...args) => { if (String(name).endsWith(".ready")) { preparationFailed = true; if (failureMode === "timeout") return new Promise(() => {}); throw Object.assign(new Error("transient metadata failure"), { code: "EIO" }); } return utimes(name, ...args); }); transferBridge.init({ sftpClients: new Map() }); const result = await transferBridge.startTransfer({ sender: createSender() }, { transferId: "local-mtime-retry", sourcePath, targetPath, sourceType: "local", targetType: "local", totalBytes: 7, resumable: true, }); assert.equal(result.error, undefined); assert.equal(preparationFailed, true); assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000); assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload"); }); }