Files
Netcatty/electron/bridges/sshConnectionPool.test.cjs
bincxz f282c58edc fix(ssh): validate reuse target and handle synchronous shell failures
Two more review findings on the connection-reuse path:

- Verify the source connection's endpoint matches the duplicate's
  requested hostname/port/username before reusing it. A saved host edited
  after the source tab connected would otherwise let the copy silently
  open on the old connection and run commands on the wrong machine.
  findReusableSession now takes the requested target and requires an exact
  endpoint match; the session records its actual SSH endpoint at connect.

- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
  synchronously (e.g. "Not connected") if the borrowed transport dropped
  between findReusableSession and the shell request; without this the
  up-front connection ref hold would leak. On a synchronous throw we now
  drop the error listener, release the ref, and fall back to a fresh
  connection.

Adds tests for endpoint mismatch and synchronous shell failure.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:07:58 +08:00

189 lines
6.3 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
findReusableSession,
} = require("./sshConnectionPool.cjs");
function makeConn() {
return {
ended: 0,
end() { this.ended += 1; },
};
}
function makeChainConn() {
return {
ended: 0,
end() { this.ended += 1; },
};
}
test("releaseConnectionRef ends transport only when the last channel closes", () => {
const conn = makeConn();
const chain = [makeChainConn(), makeChainConn()];
const owner = {};
const reused = {};
const connRef = createConnectionRef(owner, conn, chain);
assert.equal(connRef.count, 1);
acquireConnectionRef(reused, connRef);
assert.equal(connRef.count, 2);
assert.equal(reused.connRef, connRef);
// Closing the reused channel must not end the shared transport.
let ended = releaseConnectionRef(reused);
assert.equal(ended, false);
assert.equal(conn.ended, 0);
assert.equal(reused.connRef, null);
// Closing the last (owner) channel ends the transport + chain.
ended = releaseConnectionRef(owner);
assert.equal(ended, true);
assert.equal(conn.ended, 1);
assert.equal(chain[0].ended, 1);
assert.equal(chain[1].ended, 1);
assert.equal(owner.connRef, null);
});
test("releaseConnectionRef keeps siblings alive when the owner closes first", () => {
const conn = makeConn();
const owner = {};
const reused = {};
const connRef = createConnectionRef(owner, conn, []);
acquireConnectionRef(reused, connRef);
// Owner (the session that opened the connection) closes while a copy is live.
assert.equal(releaseConnectionRef(owner), false);
assert.equal(conn.ended, 0, "connection must survive for the remaining copy");
// The remaining copy is the last holder and ends the transport.
assert.equal(releaseConnectionRef(reused), true);
assert.equal(conn.ended, 1);
});
test("releaseConnectionRef is idempotent per session", () => {
const conn = makeConn();
const owner = {};
const connRef = createConnectionRef(owner, conn, []);
acquireConnectionRef({}, connRef); // bump count to 2 so a double release can't reach 0 by itself
assert.equal(releaseConnectionRef(owner), false);
// A second release for the same session must be a no-op (no double decrement).
assert.equal(releaseConnectionRef(owner), false);
assert.equal(connRef.count, 1);
assert.equal(conn.ended, 0);
});
test("releaseConnectionRef on a session without a descriptor is a safe no-op", () => {
assert.equal(releaseConnectionRef({}), false);
assert.equal(releaseConnectionRef(null), false);
assert.equal(releaseConnectionRef(undefined), false);
});
test("single-channel connection ends immediately on release", () => {
const conn = makeConn();
const chain = [makeChainConn()];
const owner = {};
createConnectionRef(owner, conn, chain);
assert.equal(releaseConnectionRef(owner), true);
assert.equal(conn.ended, 1);
assert.equal(chain[0].ended, 1);
});
test("findReusableSession returns a live interactive SSH shell session", () => {
const sessions = new Map();
const source = {
conn: { _sock: { destroyed: false } },
stream: {},
connRef: { count: 1 },
};
sessions.set("src", source);
assert.equal(findReusableSession(sessions, "src"), source);
});
test("findReusableSession rejects sessions missing a usable connection", () => {
const sessions = new Map();
// Missing stream (e.g. SFTP-only session)
sessions.set("no-stream", { conn: {}, connRef: { count: 1 } });
assert.equal(findReusableSession(sessions, "no-stream"), null);
// Missing connRef (not started through the shell path / already torn down)
sessions.set("no-ref", { conn: {}, stream: {} });
assert.equal(findReusableSession(sessions, "no-ref"), null);
// Missing conn (local/telnet/serial session)
sessions.set("no-conn", { stream: {}, connRef: { count: 1 } });
assert.equal(findReusableSession(sessions, "no-conn"), null);
// Destroyed underlying socket
sessions.set("dead", {
conn: { _sock: { destroyed: true } },
stream: {},
connRef: { count: 1 },
});
assert.equal(findReusableSession(sessions, "dead"), null);
});
test("findReusableSession handles missing inputs gracefully", () => {
assert.equal(findReusableSession(null, "x"), null);
assert.equal(findReusableSession(new Map(), ""), null);
assert.equal(findReusableSession(new Map(), "absent"), null);
});
test("findReusableSession enforces an exact target endpoint match", () => {
const sessions = new Map();
const source = {
conn: { _sock: { destroyed: false } },
stream: {},
connRef: { count: 1 },
_reuseEndpoint: { hostname: "10.0.0.1", port: 22, username: "alice" },
};
sessions.set("src", source);
// Exact match -> reusable.
assert.equal(
findReusableSession(sessions, "src", { hostname: "10.0.0.1", port: 22, username: "alice" }),
source,
);
// Omitted port defaults to 22 and still matches.
assert.equal(
findReusableSession(sessions, "src", { hostname: "10.0.0.1", username: "alice" }),
source,
);
// Different host / port / user -> not reusable.
assert.equal(findReusableSession(sessions, "src", { hostname: "10.0.0.2", port: 22, username: "alice" }), null);
assert.equal(findReusableSession(sessions, "src", { hostname: "10.0.0.1", port: 2222, username: "alice" }), null);
assert.equal(findReusableSession(sessions, "src", { hostname: "10.0.0.1", port: 22, username: "bob" }), null);
// A root source matches a request that omits the username (defaults to root).
sessions.set("root-src", {
conn: { _sock: { destroyed: false } },
stream: {},
connRef: { count: 1 },
_reuseEndpoint: { hostname: "10.0.0.9", port: 22, username: "root" },
});
assert.ok(findReusableSession(sessions, "root-src", { hostname: "10.0.0.9" }));
});
test("findReusableSession refuses reuse when the source has no recorded endpoint", () => {
const sessions = new Map();
sessions.set("src", {
conn: { _sock: { destroyed: false } },
stream: {},
connRef: { count: 1 },
// no _reuseEndpoint
});
// With a requested target we can't prove same-host, so refuse.
assert.equal(findReusableSession(sessions, "src", { hostname: "10.0.0.1" }), null);
// Without a requested target (legacy callers), endpoint check is skipped.
assert.ok(findReusableSession(sessions, "src"));
});