Add SFTP fallback for missing rz uploads
This commit is contained in:
@@ -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,
|
||||
|
||||
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal file
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal file
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
electron/bridges/tempDirBridge.test.cjs
Normal file
19
electron/bridges/tempDirBridge.test.cjs
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user