"use strict"; const { describe, it, beforeEach, afterEach } = 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 { EventEmitter } = require("node:events"); const { Readable } = require("node:stream"); const { createScpBackend, createSshExecAdapters, createTransferFromAbortSignal, } = require("./scpBackend.cjs"); const { buildFileControlLine, buildAck, SCP_OK, } = require("./scpProtocol.cjs"); function createMockStream() { const ee = new EventEmitter(); ee.writable = true; ee.readable = true; ee.stderr = new EventEmitter(); ee._chunks = []; ee.write = (buf, cb) => { ee._chunks.push(Buffer.from(buf)); ee.emit("_write", Buffer.from(buf)); if (typeof cb === "function") cb(); return true; }; ee.end = (cb) => { ee.emit("end"); if (typeof cb === "function") cb(); }; ee.close = () => { ee.emit("close"); }; ee.destroy = () => { ee.emit("close"); }; ee.pushFromRemote = (buf) => { ee.emit("data", Buffer.from(buf)); }; return ee; } describe("scpBackend browse/manage with fake exec", () => { let commands; let backend; beforeEach(() => { commands = []; backend = createScpBackend({ exec: async (command) => { commands.push({ type: "exec", command }); if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 }; if (command.includes("rm ") || command.includes("rmdir")) return { stdout: "", stderr: "", code: 0 }; if (command.includes("mv ")) return { stdout: "", stderr: "", code: 0 }; if (command.includes("chmod ")) return { stdout: "", stderr: "", code: 0 }; if (command.includes("$HOME") || command.includes('printf "B64:"') || command.includes('printf "RAW:')) { const b64 = Buffer.from("/home/test", "utf8").toString("base64"); return { stdout: `B64:${b64}\n`, stderr: "", code: 0 }; } if (command.includes("for f in")) { const name = Buffer.from("readme.txt").toString("base64"); const dir = Buffer.from("docs").toString("base64"); return { stdout: `f|-rw-r--r--|5|1700000000|${name}\nd|drwxr-xr-x|0|1700000001|${dir}\n`, stderr: "", code: 0, }; } if (command.includes("wc -c") || command.includes("if [ ! -e")) { // stat command return { stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt\n", stderr: "", code: 0, }; } return { stdout: "", stderr: "", code: 0 }; }, execStream: async (command) => { commands.push({ type: "execStream", command }); const stream = createMockStream(); setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); stream.on("_write", (buf) => { const text = buf.toString("utf8"); if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) { setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); } }); return stream; }, }); }); it("lists directory entries via shell", async () => { const entries = await backend.list("/home/test"); assert.equal(entries.length, 2); assert.equal(entries[0].name, "readme.txt"); assert.equal(entries[0].type, "file"); assert.equal(entries[1].name, "docs"); assert.equal(entries[1].type, "directory"); assert.ok(commands.some((c) => c.command.includes("cd '/home/test'"))); }); it("resolves symlink linkTarget so directory links are navigable", async () => { backend = createScpBackend({ exec: async (command) => { commands.push({ type: "exec", command }); if (command.includes("for f in") || command.includes("cd ")) { const link = Buffer.from("shared").toString("base64"); const fileLink = Buffer.from("alias.txt").toString("base64"); return { stdout: `l|lrwxrwxrwx|0|1700000000|${link}\n` + `l|lrwxrwxrwx|0|1700000000|${fileLink}\n`, stderr: "", code: 0, }; } // resolveSymlinkTargetType probes if (command.includes("/home/test/shared") || command.includes("shared")) { if (command.includes('echo directory') || command.includes("[ -d")) { return { stdout: "directory\n", stderr: "", code: 0 }; } } if (command.includes("alias.txt")) { return { stdout: "file\n", stderr: "", code: 0 }; } return { stdout: "file\n", stderr: "", code: 0 }; }, execStream: async () => createMockStream(), }); const entries = await backend.list("/home/test"); assert.equal(entries.length, 2); const shared = entries.find((e) => e.name === "shared"); const alias = entries.find((e) => e.name === "alias.txt"); assert.equal(shared?.type, "symlink"); assert.equal(shared?.linkTarget, "directory"); assert.equal(alias?.type, "symlink"); assert.equal(alias?.linkTarget, "file"); }); it("mkdir rename delete and chmod issue quoted shell commands", async () => { await backend.mkdir("/tmp/a b/c"); await backend.rename("/tmp/a b/c", "/tmp/a b/d"); await backend.remove("/tmp/a b/d", { recursive: true }); await backend.chmod("/tmp/file", "644"); await backend.chmod("/tmp/zero-mode", 0); assert.ok(commands.some((c) => c.command.includes("mkdir -p -- '/tmp/a b/c'"))); assert.ok(commands.some((c) => c.command.includes("mv -- '/tmp/a b/c' '/tmp/a b/d'"))); assert.ok(commands.some((c) => c.command.includes("rm -rf -- '/tmp/a b/d'"))); assert.ok(commands.some((c) => c.command.includes("chmod 644 -- '/tmp/file'"))); assert.ok(commands.some((c) => c.command.includes("chmod 000 -- '/tmp/zero-mode'"))); }); it("unlinks without a directory probe or recursive delete", async () => { await backend.unlink("/tmp/a b/link"); const matching = commands.filter((c) => c.command.includes("/tmp/a b/link")); assert.equal(matching.length, 1); assert.match(matching[0].command, /rm -f -- '\/tmp\/a b\/link'/); assert.doesNotMatch(matching[0].command, /rm -rf/); assert.doesNotMatch(matching[0].command, /rmdir/); }); it("stats a remote path", async () => { const st = await backend.stat("/home/test/readme.txt"); assert.equal(st.size, 5); assert.equal(st.isDirectory, false); const statCommand = commands.find((entry) => entry.command.includes("if [ ! -e"))?.command || ""; assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing"); }); it("resolves home directory", async () => { const home = await backend.homeDir(); assert.equal(home, "/home/test"); }); it("falls back to gb18030 when $HOME bytes are not valid UTF-8", async () => { const iconv = require("iconv-lite"); const pathBytes = Buffer.concat([ Buffer.from("/home/", "utf8"), iconv.encode("用户", "gb18030"), ]); const b64 = pathBytes.toString("base64"); let detected = null; backend = createScpBackend({ exec: async (command) => { if (command.includes("$HOME") || command.includes('printf "B64:"')) { return { stdout: `B64:${b64}\n`, stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; }, execStream: async () => createMockStream(), }); const home = await backend.homeDir({ encoding: "utf-8", onDetectedEncoding: (enc) => { detected = enc; }, }); assert.equal(home, "/home/用户"); assert.equal(detected, "gb18030"); }); }); it("SCP in-memory reads reject a grown file from its protocol header", async () => { const stream = createMockStream(); let ackCount = 0; stream.on("_write", (buf) => { if (!(buf.length === 1 && buf[0] === SCP_OK)) return; ackCount += 1; if (ackCount === 1) { setImmediate(() => stream.pushFromRemote( buildFileControlLine({ mode: 0o644, size: 5, name: "grown.bin" }), )); } else if (ackCount === 2) { setImmediate(() => stream.pushFromRemote( Buffer.concat([Buffer.from("ABCDE"), Buffer.from([0x00])]), )); } }); const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => stream, }); await assert.rejects( backend.readFile("/remote/grown.bin", { maxBytes: 4 }), /too large.*4 bytes.*download/i, ); assert.equal(ackCount, 1, "oversized content must not be acknowledged for transfer"); }); describe("scpBackend upload/download with fake scp streams", () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-")); }); afterEach(() => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } }); it("uploads a local file through scp -t handshake and reports progress", async () => { const localFile = path.join(tmpDir, "local.txt"); fs.writeFileSync(localFile, "hello-scp"); const written = []; let progressCalls = []; const backend = createScpBackend({ exec: async (command) => { if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 }; return { stdout: "", stderr: "", code: 0 }; }, execStream: async (command) => { assert.match(command, /scp -t -- /); const stream = createMockStream(); // Delay ready ACK until after waitForAck attaches (setImmediate > microtask). setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); stream.on("_write", (buf) => { written.push(Buffer.from(buf)); const text = buf.toString("utf8"); // ACK after control line and after trailing NUL if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) { setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); } }); return stream; }, }); const transfer = { cancelled: false, abort: null }; await backend.uploadFile(localFile, "/remote/dir/local.txt", { transfer, onProgress: (t, total) => progressCalls.push([t, total]), }); const joined = Buffer.concat(written).toString("utf8"); assert.match(joined, /C0[0-7]{3} 9 local\.txt\n/); assert.ok(joined.includes("hello-scp")); assert.ok(progressCalls.length > 0); assert.equal(progressCalls[progressCalls.length - 1][0], 9); }); it("uploads from a caller-provided verified read stream", async () => { const localFile = path.join(tmpDir, "local.txt"); fs.writeFileSync(localFile, "local-data"); const verifiedPayload = Buffer.from("safe-stream"); const written = []; let opened = 0; const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => { const stream = createMockStream(); setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); stream.on("_write", (buf) => { written.push(Buffer.from(buf)); const text = buf.toString("utf8"); if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) { setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); } }); return stream; }, }); await backend.uploadFile(localFile, "/remote/local.txt", { fileSize: verifiedPayload.length, openReadStream() { opened += 1; return Readable.from([verifiedPayload]); }, }); const joined = Buffer.concat(written); assert.equal(opened, 1); assert.equal(joined.includes(verifiedPayload), true); assert.equal(joined.includes(Buffer.from("local-data")), false); }); it("rejects verified read streams whose bytes do not match the declared size", async () => { const localFile = path.join(tmpDir, "local.txt"); fs.writeFileSync(localFile, "local-data"); async function runCase(payload, expectedSize, message) { const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => { const stream = createMockStream(); setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); stream.on("_write", (buf) => { const text = buf.toString("utf8"); if (text.startsWith("C")) { setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); } }); return stream; }, }); await assert.rejects( backend.uploadFile(localFile, "/remote/local.txt", { fileSize: expectedSize, openReadStream: () => Readable.from([Buffer.from(payload)]), }), message, ); } await runCase("short", 10, /ended early/); await runCase("too-many-bytes", 5, /exceeded its declared size/); }); it("waits for verified read cleanup before returning a stream error", async () => { const localFile = path.join(tmpDir, "local.txt"); fs.writeFileSync(localFile, "local-data"); let resolveCleanup; const cleanup = new Promise((resolve) => { resolveCleanup = resolve; }); let uploadSettled = false; const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => { const stream = createMockStream(); setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); stream.on("_write", (buf) => { if (buf.toString("utf8").startsWith("C")) { setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK]))); } else if (buf.toString("utf8") === "local-data") { setImmediate(() => { stream.emit("error", new Error("remote write failed")); }); } }); return stream; }, }); let uploadError = null; const running = backend.uploadFile(localFile, "/remote/local.txt", { fileSize: 10, openReadStream: () => ({ stream: Readable.from([Buffer.from("local-data")]), completed: cleanup, }), }).then( () => { uploadSettled = true; }, (err) => { uploadSettled = true; uploadError = err; }, ); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(uploadSettled, false); resolveCleanup(); await running; assert.match(uploadError?.message || "", /remote write failed/); }); it("downloads via scp -f parser into a local file", async () => { const localOut = path.join(tmpDir, "out.bin"); const payload = Buffer.from("ABCD"); const backend = createScpBackend({ exec: async (command) => { if (command.includes("if [ ! -e")) { return { stdout: "f|-rw-r--r--|4|1700000000|/remote/x.bin\n", stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; }, execStream: async (command) => { assert.match(command, /scp -f -- /); const stream = createMockStream(); let ackCount = 0; stream.on("_write", (buf) => { if (!(buf[0] === SCP_OK && buf.length === 1)) return; ackCount += 1; if (ackCount === 1) { // Client ready → send control setImmediate(() => { stream.pushFromRemote(buildFileControlLine({ mode: 0o644, size: 4, name: "x.bin" })); }); } else if (ackCount === 2) { // Client accepted control → send data + trailing NUL setImmediate(() => { stream.pushFromRemote(Buffer.concat([payload, Buffer.from([0x00])])); }); } }); return stream; }, }); const progress = []; const result = await backend.downloadFile("/remote/x.bin", localOut, { fileSize: 4, onProgress: (t, total) => progress.push([t, total]), }); assert.equal(fs.readFileSync(localOut).toString(), "ABCD"); assert.ok(progress.length > 0); assert.deepEqual(result, { fileSize: 4, transferred: 4 }); }); it("cleans AbortSignal listeners after successful, failed, and early-closed downloads", async () => { const runCase = async (outcome) => { const listeners = new Set(); let addCalls = 0; let removeCalls = 0; const signal = { aborted: false, addEventListener(type, listener) { if (type !== "abort") return; addCalls += 1; listeners.add(listener); }, removeEventListener(type, listener) { if (type !== "abort") return; removeCalls += 1; listeners.delete(listener); }, }; const sshClient = { exec(_command, callback) { if (outcome === "sync-throw") throw new Error("session already closed"); const stream = createMockStream(); if (outcome === "success") { let ackCount = 0; stream.on("_write", (buf) => { if (!(buf.length === 1 && buf[0] === SCP_OK)) return; ackCount += 1; if (ackCount === 1) { setImmediate(() => stream.pushFromRemote( buildFileControlLine({ mode: 0o644, size: 4, name: "x.bin" }), )); } else if (ackCount === 2) { setImmediate(() => stream.pushFromRemote( Buffer.concat([Buffer.from("ABCD"), Buffer.from([0x00])]), )); } }); } else if (outcome === "failure") { stream.on("_write", () => setImmediate(() => stream.emit("error", new Error("remote failed")))); } callback(null, stream); if (outcome === "early-close") stream.close(); }, }; const backend = createScpBackend(createSshExecAdapters(sshClient)); if (outcome === "success") { assert.deepEqual(await backend.readFile("/remote/x.bin", { signal }), Buffer.from("ABCD")); } else { await assert.rejects( () => backend.readFile("/remote/x.bin", { signal }), outcome === "failure" ? /remote failed/ : outcome === "sync-throw" ? /session already closed/ : /closed before protocol setup/, ); } assert.equal(addCalls, 1, `${outcome}: expected one abort listener`); assert.equal(removeCalls, 1, `${outcome}: expected one abort listener cleanup`); assert.equal(listeners.size, 0, `${outcome}: abort listener leaked`); }; await runCase("success"); await runCase("failure"); await runCase("early-close"); await runCase("sync-throw"); }); it("rejects signal-only upload cancellation during the exec handoff", async () => { const controller = new AbortController(); const stream = createMockStream(); let deliverStream = null; const sshClient = { exec(_command, callback) { deliverStream = () => callback(null, stream); }, }; const backend = createScpBackend(createSshExecAdapters(sshClient)); const upload = backend.uploadBuffer(Buffer.from("payload"), "file.bin", { signal: controller.signal, }); assert.equal(typeof deliverStream, "function"); controller.abort(); deliverStream(); await assert.rejects( () => Promise.race([ upload, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]), /cancel|abort/i, ); assert.equal(stream._chunks.length, 0); }); it("rejects signal-only upload cancellation when exec never calls back", async () => { const controller = new AbortController(); let invalidations = 0; const sshClient = { exec() { // Deliberately never calls back: cancellation must settle the pending // execStream promise without waiting for an SSH channel. }, destroy() { invalidations += 1; }, }; const backend = createScpBackend(createSshExecAdapters(sshClient)); const upload = backend.uploadBuffer(Buffer.from("payload"), "file.bin", { signal: controller.signal, }); controller.abort(); await assert.rejects( () => Promise.race([ upload, new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)), ]), /cancel|abort/i, ); assert.equal(invalidations, 1); }); it("rejects upload streams that close or error before ACK listeners attach", async () => { const runCase = async (outcome) => { const sshClient = { exec(_command, callback) { const stream = createMockStream(); callback(null, stream); if (outcome === "close") stream.close(); else stream.emit("error", new Error("remote failed during handoff")); }, }; const backend = createScpBackend(createSshExecAdapters(sshClient)); await assert.rejects( () => Promise.race([ backend.uploadBuffer(Buffer.from("payload"), "file.bin"), new Promise((_, reject) => setTimeout(() => reject(new Error("handoff timed out")), 500)), ]), outcome === "close" ? /closed before waiting for ACK/ : /remote failed during handoff/, ); }; await runCase("close"); await runCase("error"); }); it("cancel aborts an in-flight upload", async () => { const localFile = path.join(tmpDir, "big.bin"); fs.writeFileSync(localFile, Buffer.alloc(1024, 7)); const transfer = { cancelled: false, abort: null }; const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => { const stream = createMockStream(); // Never send ACK — upload blocks in waitForAck until cancelled. return stream; }, }); const uploadPromise = backend.uploadFile(localFile, "/remote/big.bin", { transfer }); await new Promise((r) => setTimeout(r, 30)); transfer.cancelled = true; if (typeof transfer.abort === "function") transfer.abort(); await assert.rejects(() => uploadPromise, /cancel/i); }); it("download settles when abort closes the stream before parser listeners attach", async () => { const localOut = path.join(tmpDir, "race-closed.bin"); const transfer = { cancelled: false, abort: null }; let sawAbortInstall = false; Object.defineProperty(transfer, "abort", { configurable: true, enumerable: true, get() { return this._abort; }, set(fn) { this._abort = fn; if (sawAbortInstall || typeof fn !== "function") return; sawAbortInstall = true; // Simulate cancelTransfer winning immediately after abort is wired and // closing the stream before downloadToWritable attaches parser listeners. this.cancelled = true; queueMicrotask(() => { try { fn(); } catch { /* ignore */ } }); }, }); const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => createMockStream(), }); await assert.rejects( () => backend.downloadFile("/remote/x.bin", localOut, { fileSize: 4, transfer, }), /cancel/i, ); }); it("AbortSignal via createTransferFromAbortSignal cancels in-flight upload (AI path)", async () => { const localFile = path.join(tmpDir, "sig.bin"); fs.writeFileSync(localFile, Buffer.alloc(512, 1)); const controller = new AbortController(); const transfer = createTransferFromAbortSignal(controller.signal); assert.equal(transfer.cancelled, false); const backend = createScpBackend({ exec: async () => ({ stdout: "", stderr: "", code: 0 }), execStream: async () => createMockStream(), // no ACK }); const uploadPromise = backend.uploadFile(localFile, "/remote/sig.bin", { transfer }); await new Promise((r) => setTimeout(r, 30)); controller.abort(); assert.equal(transfer.cancelled, true); await assert.rejects(() => uploadPromise, /cancel/i); transfer.detachAbortSignal?.(); }); it("createTransferFromAbortSignal marks already-aborted signals", () => { const controller = new AbortController(); controller.abort(); const transfer = createTransferFromAbortSignal(controller.signal); assert.equal(transfer.cancelled, true); assert.equal(createTransferFromAbortSignal(null), null); }); it("list aborts when AbortSignal fires during shell exec", async () => { const controller = new AbortController(); const backend = createScpBackend({ exec: (_command, options = {}) => new Promise((resolve, reject) => { const signal = options.signal; if (signal?.aborted) { reject(new Error("Transfer cancelled")); return; } const onAbort = () => reject(new Error("Transfer cancelled")); signal?.addEventListener("abort", onAbort, { once: true }); // Never resolve until aborted (simulates hung remote). }), execStream: async () => createMockStream(), }); const listPromise = backend.list("/tmp", { signal: controller.signal }); await new Promise((r) => setTimeout(r, 20)); controller.abort(); await assert.rejects(() => listPromise, /cancel/i); }); });