Add SFTP fallback for missing rz uploads

This commit is contained in:
bincxz
2026-06-12 16:56:48 +08:00
41 changed files with 1512 additions and 86 deletions

View File

@@ -24,6 +24,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
@@ -888,6 +889,7 @@ const openConnectionApi = createOpenConnectionApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler,
hostKeyVerifier,
fs, path, net, Buffer, process, console, setTimeout, clearTimeout,
SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket,
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, findAllDefaultPrivateKeysFromHelper,

View File

@@ -0,0 +1,313 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
function makeRawPublicKey(keyType, body) {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
}
function makeKnownHost(id, hostname, rawKey) {
return {
id,
hostname,
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: crypto.createHash("sha256")
.update(rawKey)
.digest("base64")
.replace(/=+$/g, ""),
discoveredAt: 1,
};
}
function loadSftpBridgeWithMockedClients(t) {
const bridgePath = require.resolve("./sftpBridge.cjs");
const originalLoad = Module._load;
class MockJumpClient extends EventEmitter {
constructor() {
super();
MockJumpClient.instances.push(this);
this.connectOpts = null;
this.ended = false;
this.hostVerifierCalls = 0;
}
connect(opts) {
this.connectOpts = opts;
const rawKey = MockJumpClient.hostKeysByHost.get(opts.host) || MockJumpClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.emit("handshake");
this.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.emit("error", err);
});
});
}
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
const stream = new EventEmitter();
stream.end = () => {};
stream.destroy = () => {};
setImmediate(() => cb(null, stream));
}
end() {
this.ended = true;
}
destroy() {
this.ended = true;
}
}
MockJumpClient.instances = [];
MockJumpClient.hostKeysByHost = new Map();
MockJumpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted jump key");
class MockSftpClient extends EventEmitter {
constructor() {
super();
MockSftpClient.instances.push(this);
this.hostVerifierCalls = 0;
this.client = new EventEmitter();
this.client.setMaxListeners = () => {};
this.client.connectOpts = null;
this.client.connect = (opts) => {
this.client.connectOpts = opts;
const rawKey = MockSftpClient.hostKeysByHost.get(opts.host)
|| MockSftpClient.tunneledHostKey
|| MockSftpClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.client.emit("handshake");
this.client.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.client.emit("error", err);
});
});
};
this.client.sftp = (cb) => {
setImmediate(() => cb(null, new EventEmitter()));
};
this.client.end = () => {};
this.client.destroy = () => {};
}
end() {}
}
MockSftpClient.instances = [];
MockSftpClient.hostKeysByHost = new Map();
MockSftpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted target key");
MockSftpClient.tunneledHostKey = null;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockJumpClient,
utils: { parseKey: () => new Error("no key") },
};
}
if (request === "ssh2-sftp-client") {
return MockSftpClient;
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
const bridge = require("./sftpBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
Module._load = originalLoad;
});
return { bridge, MockJumpClient, MockSftpClient };
}
function makeSender({ rejectHostKeyPrompts = false } = {}) {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
const { handleResponse } = require("./hostKeyVerifier.cjs");
queueMicrotask(() => {
handleResponse(null, {
requestId: payload.requestId,
accept: false,
});
});
}
},
};
}
test("SFTP direct connections verify target host keys against known hosts", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender();
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
MockSftpClient.hostKeysByHost.set("target.example.com", rawTargetKey);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await bridge.openSftp(
{ sender },
{
sessionId: "sftp-direct-host-key",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [makeKnownHost("kh-target", "target.example.com", rawTargetKey)],
},
);
const connectOpts = MockSftpClient.instances[0].client.connectOpts;
assert.equal(typeof connectOpts.hostVerifier, "function");
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("SFTP jump-host chains verify hop and target host keys against known hosts", async (t) => {
const { bridge, MockJumpClient, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender();
const rawJumpKey = makeRawPublicKey("ssh-ed25519", "trusted sftp jump key");
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
MockJumpClient.hostKeysByHost.set("bastion.example.com", rawJumpKey);
MockSftpClient.tunneledHostKey = rawTargetKey;
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await bridge.openSftp(
{ sender },
{
sessionId: "sftp-chain-host-key",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [
makeKnownHost("kh-jump", "bastion.example.com", rawJumpKey),
makeKnownHost("kh-target", "target.example.com", rawTargetKey),
],
jumpHosts: [{
hostname: "bastion.example.com",
port: 22,
username: "jump",
password: "secret",
label: "Bastion",
}],
},
);
const jumpConnectOpts = MockJumpClient.instances[0].connectOpts;
assert.equal(typeof jumpConnectOpts.hostVerifier, "function");
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
const targetConnectOpts = MockSftpClient.instances[0].client.connectOpts;
assert.equal(typeof targetConnectOpts.hostVerifier, "function");
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("SFTP direct connections stop when target host keys are rejected", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockSftpClient.hostKeysByHost.set(
"target.example.com",
makeRawPublicKey("ssh-ed25519", "unknown sftp target key"),
);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender },
{
sessionId: "sftp-direct-host-key-rejected",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [],
},
),
/Host key rejected/,
);
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});
test("SFTP jump-host chains stop when hop host keys are rejected", async (t) => {
const { bridge, MockJumpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockJumpClient.hostKeysByHost.set(
"bastion.example.com",
makeRawPublicKey("ssh-ed25519", "unknown sftp jump key"),
);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender },
{
sessionId: "sftp-chain-host-key-rejected",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [],
jumpHosts: [{
hostname: "bastion.example.com",
port: 22,
username: "jump",
password: "secret",
label: "Bastion",
}],
},
),
/Host key rejected/,
);
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});

View File

@@ -81,6 +81,13 @@ function createOpenConnectionApi(ctx) {
},
),
};
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId: connId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
});
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
@@ -640,6 +647,13 @@ function createOpenConnectionApi(ctx) {
algorithmOverrides: options.algorithmOverrides,
}),
};
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender: event.sender,
sessionId: connId,
hostname: options.hostname,
port: options.port || 22,
knownHosts: options.knownHosts,
});
// Use the tunneled socket if we have one
if (connectionSocket) {

View File

@@ -545,6 +545,13 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
},
),
};
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
});
attachSshDebugLogger(connOpts, sshDiagnosticLogger);
logSshAlgorithms("Jump host", connOpts.algorithms, {
hostname: jump.hostname,

View File

@@ -0,0 +1,196 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
function makeRawPublicKey(keyType, body = "trusted jump host key") {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
}
function loadBridgeWithMockedSsh2(t) {
const bridgePath = require.resolve("./sshBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
class MockSSHClient extends EventEmitter {
constructor() {
super();
MockSSHClient.instances.push(this);
this.ended = false;
this.connectOpts = null;
this.hostVerifierCalls = 0;
}
connect(opts) {
this.connectOpts = opts;
const rawKey = MockSSHClient.hostKeysByHost.get(opts.host) || MockSSHClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.emit("connect");
this.emit("handshake");
this.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.emit("error", err);
});
});
}
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
const stream = new EventEmitter();
stream.destroy = () => {};
setImmediate(() => cb(null, stream));
}
end() {
this.ended = true;
}
destroy() {
this.ended = true;
}
}
MockSSHClient.instances = [];
MockSSHClient.hostKeysByHost = new Map();
MockSSHClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted key");
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => new Error("no key") },
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
Module._load = originalLoad;
});
return { bridge, MockSSHClient };
}
function makeSender({ rejectHostKeyPrompts = false } = {}) {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
const { handleResponse } = require("./hostKeyVerifier.cjs");
queueMicrotask(() => {
handleResponse(null, {
requestId: payload.requestId,
accept: false,
});
});
}
},
};
}
test("jump-host chain connections verify hop host keys against known hosts", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const sender = makeSender();
const rawKey = makeRawPublicKey("ssh-ed25519");
MockSSHClient.hostKeysByHost.set("bastion.example.com", rawKey);
const fingerprint = crypto.createHash("sha256")
.update(rawKey)
.digest("base64")
.replace(/=+$/g, "");
await bridge.connectThroughChain(
{ sender },
{
knownHosts: [{
id: "kh-jump",
hostname: "bastion.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint,
discoveredAt: 1,
}],
_defaultKeys: [],
},
[{
hostname: "bastion.example.com",
port: 22,
username: "alice",
password: "secret",
label: "Bastion",
}],
"target.example.com",
22,
"session-1",
);
assert.equal(MockSSHClient.instances.length, 1);
const connectOpts = MockSSHClient.instances[0].connectOpts;
assert.equal(typeof connectOpts.hostVerifier, "function");
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("jump-host chain connections stop when hop host keys are rejected", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockSSHClient.hostKeysByHost.set(
"bastion.example.com",
makeRawPublicKey("ssh-ed25519", "unknown jump host key"),
);
await assert.rejects(
bridge.connectThroughChain(
{ sender },
{
knownHosts: [],
_defaultKeys: [],
},
[{
hostname: "bastion.example.com",
port: 22,
username: "alice",
password: "secret",
label: "Bastion",
}],
"target.example.com",
22,
"session-1",
),
/Host key rejected/,
);
assert.equal(MockSSHClient.instances.length, 1);
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});

View File

@@ -14,6 +14,7 @@ const NETCATTY_TEMP_DIR_NAME = "Netcatty";
// Cached temp directory path
let cachedTempDir = null;
let tempFileCounter = 0;
/**
* Get the Netcatty temp directory path
@@ -143,8 +144,9 @@ async function clearTempDir() {
function getTempFilePath(fileName) {
const tempDir = getTempDir();
const timestamp = Date.now();
tempFileCounter = (tempFileCounter + 1) % 1000000;
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
return path.join(tempDir, `${timestamp}_${safeFileName}`);
return path.join(tempDir, `${timestamp}_${tempFileCounter}_${safeFileName}`);
}
/**

View File

@@ -0,0 +1,19 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
test("getTempFilePath is unique for duplicate names in the same millisecond", () => {
const originalNow = Date.now;
Date.now = () => 1234567890;
try {
const first = tempDirBridge.getTempFilePath("upload.txt");
const second = tempDirBridge.getTempFilePath("upload.txt");
assert.notEqual(first, second);
assert.equal(path.basename(first).endsWith("_upload.txt"), true);
assert.equal(path.basename(second).endsWith("_upload.txt"), true);
} finally {
Date.now = originalNow;
}
});

View File

@@ -114,9 +114,13 @@ function createZmodemSentry(opts) {
let cooldownUntil = 0;
/** Drag-drop upload queued before auto-triggering rz on the PTY. */
let dragDropUpload = null;
let dragDropStartTimer = null;
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
const dragDropStartTimeoutMs = Number.isFinite(opts.dragDropStartTimeoutMs)
? Math.max(0, opts.dragDropStartTimeoutMs)
: 15000;
function prunePendingEchoes(now = Date.now()) {
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
@@ -271,6 +275,7 @@ function createZmodemSentry(opts) {
}
function clearDragDropUpload() {
clearDragDropStartTimer();
if (dragDropUpload) {
cleanupDragDropTempFiles(dragDropUpload);
dragDropUpload = null;
@@ -278,11 +283,19 @@ function createZmodemSentry(opts) {
}
function takeDragDropUpload() {
clearDragDropStartTimer();
const upload = dragDropUpload;
dragDropUpload = null;
return upload;
}
function clearDragDropStartTimer() {
if (dragDropStartTimer) {
clearTimeout(dragDropStartTimer);
dragDropStartTimer = null;
}
}
function scheduleRemoteInterruptAfterCancel(transferRole) {
if (cancelInterruptTimer) {
clearTimeout(cancelInterruptTimer);
@@ -305,6 +318,38 @@ function createZmodemSentry(opts) {
}, 120);
}
function interruptPendingDragDropCommand() {
ignoreDetectionUntil = Date.now() + 1000;
sendExtraAbortBytes();
try { interruptRemote?.(); } catch { /* ignore */ }
if (cancelInterruptTimer) {
clearTimeout(cancelInterruptTimer);
cancelInterruptTimer = null;
}
cancelInterruptTimer = setTimeout(() => {
cancelInterruptTimer = null;
try { interruptRemote?.(); } catch { /* ignore */ }
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 120);
}
function scheduleDragDropStartTimeout() {
clearDragDropStartTimer();
if (!dragDropStartTimeoutMs) return;
dragDropStartTimer = setTimeout(() => {
dragDropStartTimer = null;
if (!dragDropUpload || active) return;
console.warn(`[ZMODEM][${label}] Drag-drop upload did not start before timeout; cancelling pending upload`);
interruptPendingDragDropCommand();
clearDragDropUpload();
safeSend(getWebContents(), "netcatty:zmodem:error", {
sessionId,
error: "ZMODEM drag-drop upload did not start",
});
}, dragDropStartTimeoutMs);
}
function isIgnorableSendKeepaliveError(errMsg) {
return Boolean(
active &&
@@ -519,7 +564,7 @@ function createZmodemSentry(opts) {
},
/** Cancel the current ZMODEM transfer. */
cancel() {
cancel(options = {}) {
if (currentZSession) {
const transferRole = currentZSession.type;
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
@@ -533,6 +578,8 @@ function createZmodemSentry(opts) {
sessionId,
error: "Transfer cancelled",
});
} else if (dragDropUpload && options.interrupt !== false) {
interruptPendingDragDropCommand();
}
clearDragDropUpload();
},
@@ -562,9 +609,18 @@ function createZmodemSentry(opts) {
};
const cmdBuf = Buffer.from(uploadCommand, "utf8");
rememberOutgoingEcho(cmdBuf);
pendingTerminalSuppression = Buffer.from(uploadCommand.replace(/\r$/, ""));
writeToRemote(cmdBuf);
const pendingEchoCount = pendingEchoes.length;
try {
rememberOutgoingEcho(cmdBuf);
pendingTerminalSuppression = Buffer.from(uploadCommand.replace(/\r$/, ""));
writeToRemote(cmdBuf);
scheduleDragDropStartTimeout();
} catch (err) {
pendingEchoes.length = pendingEchoCount;
pendingTerminalSuppression = null;
clearDragDropUpload();
throw err;
}
},
};
}
@@ -649,9 +705,8 @@ async function handleUpload(zsession, opts) {
allNames = filePaths.map((fp) => path.basename(fp));
}
const fileStats = filePaths.map((fp) => fs.statSync(fp));
try {
const fileStats = filePaths.map((fp) => fs.statSync(fp));
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
// On any failure we fall back to today's behavior (rz silently skips).

View File

@@ -100,6 +100,71 @@ test("queued drag-drop upload keeps temp files until cancel", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload interrupts the remote command when cancelled before detect", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
let interrupted = false;
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
interruptRemote: () => {
interrupted = true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 0,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
sentry.cancel();
assert.equal(fs.existsSync(tempPath), false);
assert.equal(interrupted, true);
assert.equal(writes[0].toString("utf8"), "rz\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload cleans temp files when rz never starts", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 1,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(fs.existsSync(tempPath), false);
assert.equal(writes[0].toString("utf8"), "rz\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload rejects a second pending upload", () => {
const sentry = createZmodemSentry({
sessionId: "session-1",
@@ -120,4 +185,43 @@ test("queued drag-drop upload rejects a second pending upload", () => {
}),
/already pending/,
);
sentry.cancel({ interrupt: false });
});
test("queued drag-drop upload cleans temp files when command write fails", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstTempPath = path.join(tempDir, "first.txt");
const secondTempPath = path.join(tempDir, "second.txt");
fs.writeFileSync(firstTempPath, "first");
fs.writeFileSync(secondTempPath, "second");
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => {
throw new Error("socket closed");
},
getWebContents: () => null,
});
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [firstTempPath],
remoteNames: ["first.txt"],
tempPaths: [firstTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(firstTempPath), false);
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [secondTempPath],
remoteNames: ["second.txt"],
tempPaths: [secondTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(secondTempPath), false);
fs.rmSync(tempDir, { recursive: true, force: true });
});

View File

@@ -199,7 +199,7 @@ function createBridgeRegistrar(context) {
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (session?.zmodemSentry) {
session.zmodemSentry.cancel();
session.zmodemSentry.cancel(payload.options);
}
});

View File

@@ -183,8 +183,8 @@ function createPreloadApi(ctx) {
zmodemListeners.get(sessionId).add(cb);
return () => zmodemListeners.get(sessionId)?.delete(cb);
},
cancelZmodem: (sessionId) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
cancelZmodem: (sessionId, options) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId, options });
},
startZmodemDragDropUpload: (sessionId, files, uploadCommand) => {
return ipcRenderer.invoke("netcatty:zmodem:drag-drop-upload", {