Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
989 lines
37 KiB
JavaScript
989 lines
37 KiB
JavaScript
/**
|
|
* Drive the shipped downloadSftpToLocal / uploadLocalToSftp SCP branches with
|
|
* AbortSignal — the AI/MCP transfer path must cancel mid-flight, not only
|
|
* throwIfAborted before/after.
|
|
*/
|
|
"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 sftpBridge = require("../sftpBridge.cjs");
|
|
const { createScpBackend } = require("./scpBackend.cjs");
|
|
|
|
function createMockStream() {
|
|
const ee = new EventEmitter();
|
|
ee.writable = true;
|
|
ee.readable = true;
|
|
ee.stderr = new EventEmitter();
|
|
ee.write = (buf, cb) => {
|
|
if (typeof cb === "function") cb();
|
|
return true;
|
|
};
|
|
ee.end = (cb) => { if (typeof cb === "function") cb(); };
|
|
ee.close = () => {
|
|
ee.closed = true;
|
|
ee.destroyed = true;
|
|
ee.emit("close");
|
|
};
|
|
ee.destroy = () => ee.close();
|
|
return ee;
|
|
}
|
|
|
|
describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () => {
|
|
let tmpDir;
|
|
let sftpClients;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-ai-abort-"));
|
|
sftpClients = new Map();
|
|
sftpBridge.init({
|
|
electronModule: {},
|
|
sessions: new Map(),
|
|
sftpClients,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Aborted SCP transfers can still open the fixture briefly after the test
|
|
// assertion settles. Drain that work before removing the temp directory so
|
|
// Node does not promote a late ENOENT into a file-level failure.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
});
|
|
|
|
function registerScpClient(id, { hangOnStream = true } = {}) {
|
|
const backend = createScpBackend({
|
|
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
|
execStream: async () => {
|
|
const stream = createMockStream();
|
|
if (!hangOnStream) {
|
|
// ready ACK immediately for success paths (not used in abort tests)
|
|
setImmediate(() => stream.emit("data", Buffer.from([0])));
|
|
}
|
|
// hang: never ACK so waitForAck blocks until cancel
|
|
return stream;
|
|
},
|
|
});
|
|
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
|
const client = {
|
|
client: { exec: () => {} },
|
|
sftp: null,
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
async end() {},
|
|
};
|
|
sftpClients.set(id, client);
|
|
return client;
|
|
}
|
|
|
|
it("downloadSftpToLocal rejects when AbortSignal fires mid-SCP download", async () => {
|
|
registerScpClient("scp-dl");
|
|
const controller = new AbortController();
|
|
const localPath = path.join(tmpDir, "out.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
const promise = sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-dl",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
});
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
controller.abort();
|
|
await assert.rejects(() => promise, /cancel|abort/i);
|
|
assert.deepEqual(fs.readFileSync(localPath), original);
|
|
});
|
|
|
|
it("downloadSftpToLocal settles when abort closes SCP before parser listeners attach", async () => {
|
|
const controller = new AbortController();
|
|
const backend = createScpBackend({
|
|
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
|
execStream: async (_command, options = {}) => {
|
|
const stream = createMockStream();
|
|
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
|
|
// Reproduce the transition race deterministically: the unified abort
|
|
// listener marks the transfer cancelled and this stream closes before
|
|
// downloadToWritable can attach its parser close/error listeners.
|
|
controller.abort();
|
|
return stream;
|
|
},
|
|
});
|
|
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
|
sftpClients.set("scp-transition-abort", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
|
|
const localPath = path.join(tmpDir, "transition-abort.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
const download = sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-transition-abort",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => Promise.race([
|
|
download,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|
]),
|
|
/cancel|abort/i,
|
|
);
|
|
assert.deepEqual(fs.readFileSync(localPath), original);
|
|
});
|
|
|
|
it("downloadSftpToLocal rejects when SCP closes before parser listeners attach", async () => {
|
|
const backend = createScpBackend({
|
|
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
|
execStream: async () => {
|
|
const stream = createMockStream();
|
|
stream.close();
|
|
return stream;
|
|
},
|
|
});
|
|
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
|
sftpClients.set("scp-transition-close", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
|
|
const localPath = path.join(tmpDir, "transition-close.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
const download = sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-transition-close",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => Promise.race([
|
|
download,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("close timed out")), 500)),
|
|
]),
|
|
/closed|protocol|channel/i,
|
|
);
|
|
assert.deepEqual(fs.readFileSync(localPath), original);
|
|
});
|
|
|
|
it("readSftpBinary reports AbortSignal-only SCP cancellation as cancelled", async () => {
|
|
const controller = new AbortController();
|
|
const backend = createScpBackend({
|
|
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
|
execStream: async (_command, options = {}) => {
|
|
const stream = createMockStream();
|
|
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
|
|
return stream;
|
|
},
|
|
});
|
|
sftpClients.set("scp-read-signal", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
|
|
const read = sftpBridge.readSftpBinary(null, {
|
|
sftpId: "scp-read-signal",
|
|
path: "/remote/file.bin",
|
|
abortSignal: controller.signal,
|
|
});
|
|
setImmediate(() => controller.abort());
|
|
|
|
await assert.rejects(
|
|
() => Promise.race([
|
|
read,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|
]),
|
|
/cancel|abort/i,
|
|
);
|
|
});
|
|
|
|
it("downloadSftpToLocal preserves the destination when cancellation arrives after SCP download", async () => {
|
|
const controller = new AbortController();
|
|
const downloaded = Buffer.from("new-downloaded-content");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
controller.abort();
|
|
},
|
|
};
|
|
sftpClients.set("scp-late-abort", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "late-abort.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-late-abort",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
}),
|
|
/cancel|abort/i,
|
|
);
|
|
assert.deepEqual(fs.readFileSync(localPath), original);
|
|
});
|
|
|
|
it("downloadSftpToLocal uses the SCP header size when downloading through a symlink", async () => {
|
|
const downloaded = Buffer.from("target-content-is-longer-than-link");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "symlink", isSymbolicLink: true, size: 4 };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-symlink-download", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "symlink-download.bin");
|
|
|
|
const result = await sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-symlink-download",
|
|
remotePath: "/remote/link",
|
|
localPath,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
|
});
|
|
|
|
it("downloadSftpToLocal preserves a local destination symlink", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("replacement-through-local-link");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-local-symlink", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const referentPath = path.join(tmpDir, "symlink-target.bin");
|
|
const localPath = path.join(tmpDir, "download-link.bin");
|
|
await fs.promises.writeFile(referentPath, Buffer.from("old-target-content"));
|
|
await fs.promises.symlink(path.basename(referentPath), localPath);
|
|
|
|
const result = await sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-local-symlink",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
|
assert.equal(await fs.promises.readlink(localPath), path.basename(referentPath));
|
|
assert.deepEqual(await fs.promises.readFile(referentPath), downloaded);
|
|
});
|
|
|
|
it("downloadSftpToLocal preserves a multi-level broken local symlink chain", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("replacement-through-broken-link-chain");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-broken-link-chain", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const finalPath = path.join(tmpDir, "missing-final.bin");
|
|
const intermediatePath = path.join(tmpDir, "intermediate-link.bin");
|
|
const localPath = path.join(tmpDir, "download-link-chain.bin");
|
|
await fs.promises.symlink(path.basename(finalPath), intermediatePath);
|
|
await fs.promises.symlink(path.basename(intermediatePath), localPath);
|
|
|
|
const result = await sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-broken-link-chain",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
|
assert.equal((await fs.promises.lstat(intermediatePath)).isSymbolicLink(), true);
|
|
assert.deepEqual(await fs.promises.readFile(finalPath), downloaded);
|
|
});
|
|
|
|
it("downloadSftpToLocal refuses a local symlink that points to a directory", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("must-not-replace-a-directory");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-directory-link", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const directoryPath = path.join(tmpDir, "preserved-directory");
|
|
const preservedPath = path.join(directoryPath, "keep.txt");
|
|
const localPath = path.join(tmpDir, "directory-link");
|
|
await fs.promises.mkdir(directoryPath);
|
|
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
|
|
await fs.promises.symlink(path.basename(directoryPath), localPath);
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-directory-link",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
}),
|
|
/not a regular file/i,
|
|
);
|
|
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
|
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
|
|
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
|
|
});
|
|
|
|
it("downloadSftpToLocal refuses an existing local directory", async () => {
|
|
const downloaded = Buffer.from("must-not-replace-a-directory");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-direct-directory", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const directoryPath = path.join(tmpDir, "direct-preserved-directory");
|
|
const preservedPath = path.join(directoryPath, "keep.txt");
|
|
await fs.promises.mkdir(directoryPath);
|
|
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-direct-directory",
|
|
remotePath: "/remote/file.bin",
|
|
localPath: directoryPath,
|
|
}),
|
|
/not a regular file/i,
|
|
);
|
|
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
|
|
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
|
|
});
|
|
|
|
it("downloadSftpToLocal refuses a local symbolic-link loop", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("must-not-enter-a-link-loop");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-link-loop", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const firstLink = path.join(tmpDir, "first-loop-link");
|
|
const secondLink = path.join(tmpDir, "second-loop-link");
|
|
await fs.promises.symlink(path.basename(secondLink), firstLink);
|
|
await fs.promises.symlink(path.basename(firstLink), secondLink);
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-link-loop",
|
|
remotePath: "/remote/file.bin",
|
|
localPath: firstLink,
|
|
}),
|
|
/symbolic-link loop/i,
|
|
);
|
|
assert.equal((await fs.promises.lstat(firstLink)).isSymbolicLink(), true);
|
|
assert.equal((await fs.promises.lstat(secondLink)).isSymbolicLink(), true);
|
|
});
|
|
|
|
it("downloadSftpToLocal allows a valid path to revisit one symlink", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("valid-repeated-link-path");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-repeated-link", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const nestedDir = path.join(tmpDir, "repeated-link-dir");
|
|
const upLink = path.join(nestedDir, "up");
|
|
await fs.promises.mkdir(nestedDir);
|
|
await fs.promises.symlink("..", upLink);
|
|
const localPath = path.join(upLink, path.basename(nestedDir), "up", "repeated-final.bin");
|
|
|
|
const result = await sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-repeated-link",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.deepEqual(await fs.promises.readFile(path.join(tmpDir, "repeated-final.bin")), downloaded);
|
|
assert.equal((await fs.promises.lstat(upLink)).isSymbolicLink(), true);
|
|
});
|
|
|
|
it("downloadSftpToLocal preserves the existing local file mode", {
|
|
skip: process.platform === "win32",
|
|
}, async () => {
|
|
const downloaded = Buffer.from("replacement-with-private-mode");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-local-mode", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "private-download.bin");
|
|
await fs.promises.writeFile(localPath, Buffer.from("private-old-content"), { mode: 0o600 });
|
|
await fs.promises.chmod(localPath, 0o600);
|
|
|
|
const result = await sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-local-mode",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.deepEqual(await fs.promises.readFile(localPath), downloaded);
|
|
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
|
});
|
|
|
|
it("downloadSftpToLocal stops when a parent symlink changes before promotion", {
|
|
skip: process.platform === "win32",
|
|
}, async (t) => {
|
|
const downloaded = Buffer.from("must-not-follow-the-new-parent");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-parent-link-change", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const firstDir = path.join(tmpDir, "first-parent");
|
|
const secondDir = path.join(tmpDir, "second-parent");
|
|
const parentLink = path.join(tmpDir, "parent-link");
|
|
const localPath = path.join(parentLink, "target.bin");
|
|
const firstOriginal = Buffer.from("first-original");
|
|
const secondOriginal = Buffer.from("second-original");
|
|
await fs.promises.mkdir(firstDir);
|
|
await fs.promises.mkdir(secondDir);
|
|
await fs.promises.writeFile(path.join(firstDir, "target.bin"), firstOriginal);
|
|
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
|
|
await fs.promises.symlink(path.basename(firstDir), parentLink);
|
|
|
|
const originalRename = fs.promises.rename;
|
|
let renameCalls = 0;
|
|
fs.promises.rename = async (...args) => {
|
|
await originalRename(...args);
|
|
renameCalls += 1;
|
|
if (renameCalls === 1) {
|
|
await fs.promises.unlink(parentLink);
|
|
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
|
}
|
|
};
|
|
t.after(() => { fs.promises.rename = originalRename; });
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-parent-link-change",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
}),
|
|
/target changed before replacement/i,
|
|
);
|
|
assert.equal(await fs.promises.readlink(parentLink), path.basename(secondDir));
|
|
assert.deepEqual(await fs.promises.readFile(path.join(firstDir, "target.bin")), firstOriginal);
|
|
assert.deepEqual(await fs.promises.readFile(path.join(secondDir, "target.bin")), secondOriginal);
|
|
const leftovers = (await fs.promises.readdir(secondDir))
|
|
.filter((name) => name.includes(".netcatty-") || name.includes(".backup"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
|
|
skip: process.platform === "win32",
|
|
}, async (t) => {
|
|
const downloaded = Buffer.from("replacement-that-must-not-publish");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-mode-setup-failure", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "mode-setup-failure.bin");
|
|
const original = Buffer.from("private-original");
|
|
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
|
await fs.promises.chmod(localPath, 0o600);
|
|
|
|
const originalChmod = fs.promises.chmod;
|
|
fs.promises.chmod = async (filePath, mode) => {
|
|
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
|
const error = new Error("injected chmod failure");
|
|
error.code = "EPERM";
|
|
throw error;
|
|
}
|
|
return originalChmod(filePath, mode);
|
|
};
|
|
t.after(() => { fs.promises.chmod = originalChmod; });
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-mode-setup-failure",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
}),
|
|
/chmod failure/i,
|
|
);
|
|
assert.deepEqual(await fs.promises.readFile(localPath), original);
|
|
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
|
const leftovers = (await fs.promises.readdir(tmpDir))
|
|
.filter((name) => name.includes("mode-setup-failure.bin.netcatty-"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
it("downloadSftpToLocal rechecks the target after mode setup", {
|
|
skip: process.platform === "win32",
|
|
}, async (t) => {
|
|
const downloaded = Buffer.from("must-not-replace-late-directory");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-late-directory", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "late-directory-target");
|
|
const savedOriginalPath = path.join(tmpDir, "saved-late-original.bin");
|
|
const original = Buffer.from("original-before-late-change");
|
|
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
|
|
|
const originalChmod = fs.promises.chmod;
|
|
let releaseChmod;
|
|
let markChmodStarted;
|
|
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
|
|
fs.promises.chmod = async (filePath, mode) => {
|
|
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
|
markChmodStarted();
|
|
await new Promise((resolve) => { releaseChmod = resolve; });
|
|
}
|
|
return originalChmod(filePath, mode);
|
|
};
|
|
t.after(() => { fs.promises.chmod = originalChmod; });
|
|
|
|
const running = sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-late-directory",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
await chmodStarted;
|
|
await fs.promises.rename(localPath, savedOriginalPath);
|
|
await fs.promises.mkdir(localPath);
|
|
releaseChmod();
|
|
|
|
await assert.rejects(() => running, /not a regular file/i);
|
|
assert.equal((await fs.promises.stat(localPath)).isDirectory(), true);
|
|
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
|
|
const leftovers = (await fs.promises.readdir(tmpDir))
|
|
.filter((name) => name.includes("late-directory-target.netcatty-"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
it("downloadSftpToLocal does not overwrite a same-mode file that appears during mode setup", {
|
|
skip: process.platform === "win32",
|
|
}, async (t) => {
|
|
const downloaded = Buffer.from("must-not-replace-a-new-owner-file");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
|
return { fileSize: downloaded.length, transferred: downloaded.length };
|
|
},
|
|
};
|
|
sftpClients.set("scp-same-mode-replacement", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "same-mode-replacement.bin");
|
|
const savedOriginalPath = path.join(tmpDir, "saved-same-mode-original.bin");
|
|
const original = Buffer.from("original-before-replacement");
|
|
const newOwnerContent = Buffer.from("new-owner-content");
|
|
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
|
await fs.promises.chmod(localPath, 0o600);
|
|
|
|
const originalChmod = fs.promises.chmod;
|
|
let releaseChmod;
|
|
let markChmodStarted;
|
|
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
|
|
fs.promises.chmod = async (filePath, mode) => {
|
|
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
|
markChmodStarted();
|
|
await new Promise((resolve) => { releaseChmod = resolve; });
|
|
}
|
|
return originalChmod(filePath, mode);
|
|
};
|
|
t.after(() => { fs.promises.chmod = originalChmod; });
|
|
|
|
const running = sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-same-mode-replacement",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
});
|
|
await chmodStarted;
|
|
await fs.promises.rename(localPath, savedOriginalPath);
|
|
await fs.promises.writeFile(localPath, newOwnerContent, { mode: 0o600 });
|
|
await fs.promises.chmod(localPath, 0o600);
|
|
releaseChmod();
|
|
|
|
await assert.rejects(() => running, /target changed before replacement/i);
|
|
assert.deepEqual(await fs.promises.readFile(localPath), newOwnerContent);
|
|
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
|
|
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
|
const leftovers = (await fs.promises.readdir(tmpDir))
|
|
.filter((name) => name.includes("same-mode-replacement.bin.netcatty-"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
it("downloadSftpToLocal restores the destination when cancellation arrives during promotion", async (t) => {
|
|
const controller = new AbortController();
|
|
const downloaded = Buffer.from("new-downloaded-content");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
},
|
|
};
|
|
sftpClients.set("scp-promotion-abort", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "promotion-abort.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
|
|
const originalRename = fs.promises.rename;
|
|
let renameCalls = 0;
|
|
fs.promises.rename = async (...args) => {
|
|
await originalRename(...args);
|
|
renameCalls += 1;
|
|
if (renameCalls === 2) controller.abort();
|
|
};
|
|
t.after(() => { fs.promises.rename = originalRename; });
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-promotion-abort",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
}),
|
|
/cancel|abort/i,
|
|
);
|
|
assert.deepEqual(fs.readFileSync(localPath), original);
|
|
});
|
|
|
|
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
|
|
const controller = new AbortController();
|
|
const downloaded = Buffer.from("new-downloaded-content");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
},
|
|
};
|
|
sftpClients.set("scp-final-rename-abort", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "final-rename-abort.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
|
|
const originalLink = fs.promises.link;
|
|
let published = false;
|
|
fs.promises.link = async (...args) => {
|
|
await originalLink(...args);
|
|
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
|
|
published = true;
|
|
controller.abort();
|
|
}
|
|
};
|
|
t.after(() => { fs.promises.link = originalLink; });
|
|
|
|
await assert.doesNotReject(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-final-rename-abort",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
}),
|
|
);
|
|
assert.equal(published, true);
|
|
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
|
});
|
|
|
|
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
|
|
const controller = new AbortController();
|
|
const downloaded = Buffer.from("new-downloaded-content");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
},
|
|
};
|
|
sftpClients.set("scp-rollback-failure", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "rollback-failure.bin");
|
|
const original = Buffer.from("existing-local-content");
|
|
fs.writeFileSync(localPath, original);
|
|
|
|
const originalRename = fs.promises.rename;
|
|
const originalLink = fs.promises.link;
|
|
let backupPath = null;
|
|
fs.promises.rename = async (...args) => {
|
|
await originalRename(...args);
|
|
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
|
backupPath = args[1];
|
|
controller.abort();
|
|
}
|
|
};
|
|
fs.promises.link = async (...args) => {
|
|
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
|
return originalLink(...args);
|
|
};
|
|
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-rollback-failure",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
}),
|
|
(error) => {
|
|
assert.match(error.message, /Recovery files preserved/);
|
|
assert.match(error.message, /Backup:/);
|
|
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
|
return true;
|
|
},
|
|
);
|
|
assert.ok(backupPath);
|
|
assert.deepEqual(fs.readFileSync(backupPath), original);
|
|
});
|
|
|
|
it("downloadSftpToLocal reports both recovery files when pre-publish restoration fails", async (t) => {
|
|
const controller = new AbortController();
|
|
const downloaded = Buffer.from("new-downloaded-content");
|
|
const backend = {
|
|
async stat() {
|
|
return { type: "file", isDirectory: false, size: downloaded.length };
|
|
},
|
|
async downloadFile(_remotePath, localPath) {
|
|
await fs.promises.writeFile(localPath, downloaded);
|
|
},
|
|
};
|
|
sftpClients.set("scp-pre-publish-rollback-failure", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "pre-publish-rollback-failure.bin");
|
|
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
|
|
|
|
const originalRename = fs.promises.rename;
|
|
let renameCalls = 0;
|
|
let readyPath = null;
|
|
let backupPath = null;
|
|
fs.promises.rename = async (...args) => {
|
|
renameCalls += 1;
|
|
await originalRename(...args);
|
|
if (renameCalls === 1) readyPath = args[1];
|
|
if (renameCalls === 2) {
|
|
backupPath = args[1];
|
|
controller.abort();
|
|
}
|
|
};
|
|
const originalLink = fs.promises.link;
|
|
fs.promises.link = async (...args) => {
|
|
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
|
return originalLink(...args);
|
|
};
|
|
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
|
|
|
await assert.rejects(
|
|
() => sftpBridge.downloadSftpToLocal(null, {
|
|
sftpId: "scp-pre-publish-rollback-failure",
|
|
remotePath: "/remote/file.bin",
|
|
localPath,
|
|
abortSignal: controller.signal,
|
|
}),
|
|
(error) => {
|
|
assert.match(error.message, /Recovery files preserved/);
|
|
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
return true;
|
|
},
|
|
);
|
|
assert.equal(fs.existsSync(readyPath), true);
|
|
assert.equal(fs.existsSync(backupPath), true);
|
|
});
|
|
|
|
it("uploadLocalToSftp rejects when AbortSignal fires mid-SCP upload", async () => {
|
|
registerScpClient("scp-up");
|
|
const localPath = path.join(tmpDir, "in.bin");
|
|
fs.writeFileSync(localPath, Buffer.alloc(256, 9));
|
|
const controller = new AbortController();
|
|
const promise = sftpBridge.uploadLocalToSftp(null, {
|
|
sftpId: "scp-up",
|
|
localPath,
|
|
remotePath: "/remote/in.bin",
|
|
abortSignal: controller.signal,
|
|
});
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
controller.abort();
|
|
await assert.rejects(() => promise, /cancel|abort/i);
|
|
});
|
|
|
|
it("uploadLocalToSftp aborts while SCP target inspection is still pending", async () => {
|
|
let uploadCalls = 0;
|
|
const backend = {
|
|
async stat(_remotePath, options = {}) {
|
|
await new Promise((resolve, reject) => {
|
|
if (options.signal?.aborted) {
|
|
reject(new Error("Transfer cancelled"));
|
|
return;
|
|
}
|
|
options.signal?.addEventListener(
|
|
"abort",
|
|
() => reject(new Error("Transfer cancelled")),
|
|
{ once: true },
|
|
);
|
|
});
|
|
},
|
|
async uploadFile() {
|
|
uploadCalls += 1;
|
|
},
|
|
};
|
|
sftpClients.set("scp-setup-abort", {
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
const localPath = path.join(tmpDir, "setup.bin");
|
|
fs.writeFileSync(localPath, Buffer.alloc(16, 1));
|
|
const controller = new AbortController();
|
|
const startedAt = Date.now();
|
|
const promise = sftpBridge.uploadLocalToSftp(null, {
|
|
sftpId: "scp-setup-abort",
|
|
localPath,
|
|
remotePath: "/remote/setup.bin",
|
|
abortSignal: controller.signal,
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
await assert.rejects(() => promise, /cancel|abort/i);
|
|
assert.equal(uploadCalls, 0);
|
|
assert.ok(Date.now() - startedAt < 1000);
|
|
// Keep the fixture alive until any in-flight open from the aborted setup
|
|
// path has a chance to settle against the still-present file.
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
});
|
|
|
|
it("legacy entry points delegate cancellation to the unified transfer engine", () => {
|
|
const src = fs.readFileSync(path.join(__dirname, "../sftpBridge.cjs"), "utf8");
|
|
assert.doesNotMatch(src, /cancelledFlag/);
|
|
assert.match(
|
|
src,
|
|
/async function downloadSftpToLocal\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "download"\);\s*\}/,
|
|
);
|
|
assert.match(
|
|
src,
|
|
/async function uploadLocalToSftp\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "upload"\);\s*\}/,
|
|
);
|
|
const unifiedIdx = src.indexOf("async function runUnifiedSftpTransfer");
|
|
const unifiedBlock = src.slice(unifiedIdx, src.indexOf("async function downloadSftpToLocal", unifiedIdx));
|
|
assert.match(unifiedBlock, /transferBridge\.startTransfer/);
|
|
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
|
|
});
|
|
});
|