Files
my-pi/pi-ssh/test/ssh2-transport.test.ts
T

360 lines
14 KiB
TypeScript

import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
import type { SshHostConfig } from "../src/config.ts";
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
function fixtureKey(label = "fixture"): Buffer {
const algorithm = Buffer.from("ssh-ed25519");
const length = Buffer.alloc(4);
length.writeUInt32BE(algorithm.length);
return Buffer.concat([length, algorithm, Buffer.from(label)]);
}
class FakeChannel extends EventEmitter {
stderr = new EventEmitter();
closeCalls = 0;
signals: string[] = [];
close(): void { this.closeCalls += 1; }
signal(value: string): void { this.signals.push(value); }
}
type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;
class FakeClient extends EventEmitter {
connectConfig?: ConnectConfig;
command?: string;
channel?: FakeChannel;
sftpCalls = 0;
ended = false;
destroyed = false;
sftpValue?: SFTPWrapper;
connectAction?: ConnectAction;
execAction?: ExecAction;
connect(config: ConnectConfig): this {
this.connectConfig = config;
queueMicrotask(() => {
if (this.connectAction) this.connectAction(config, this);
else this.emit("ready");
});
return this;
}
exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
this.command = command;
if (this.execAction) {
this.execAction(command, callback);
return;
}
const channel = new FakeChannel();
this.channel = channel;
callback(undefined, channel as ClientChannel);
queueMicrotask(() => {
channel.emit("data", Buffer.from("stdout\n"));
channel.stderr.emit("data", Buffer.from("stderr\n"));
channel.emit("close", 7);
});
}
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
this.sftpCalls += 1;
if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
else callback(undefined, this.sftpValue);
}
end(): this {
this.ended = true;
this.emit("close");
return this;
}
destroy(): this {
this.destroyed = true;
return this;
}
}
class FakeSftp {
readonly files = new Map<string, Buffer>();
readonly directories = new Set<string>(["/"]);
readonly openCalls: Array<{ path: string; flags: string }> = [];
readonly writes: string[] = [];
readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
readonly unlinks: string[] = [];
failStandardRename = false;
ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;
constructor(atomicRename = true) {
if (atomicRename) {
this.ext_openssh_rename = (from, to, callback) => {
this.renames.push({ from, to, atomic: true });
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
};
}
}
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
const value = this.files.get(path);
if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(value));
}
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
this.writes.push(path);
this.files.set(path, Buffer.from(data));
callback();
}
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
this.openCalls.push({ path, flags });
if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(path));
}
close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }
mkdir(path: string, callback: (error?: Error) => void): void {
if (this.directories.has(path)) { callback(new Error("already exists")); return; }
this.directories.add(path);
callback();
}
stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
if (this.directories.has(path)) callback(undefined, {});
else callback(new Error(`missing directory: ${path}`), {});
}
rename(from: string, to: string, callback: (error?: Error) => void): void {
this.renames.push({ from, to, atomic: false });
if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
}
unlink(path: string, callback: (error?: Error) => void): void {
this.unlinks.push(path);
this.files.delete(path);
callback();
}
}
function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
return {
hostName: "host.example.test",
user: "builder",
port: 2222,
auth: { type: "password", password: "secret", method },
hostKey: fingerprintHostKey(fixtureKey()),
};
}
async function connectedTransport(
sftp?: FakeSftp,
host: SshHostConfig = passwordHost(),
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
const fake = new FakeClient();
if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
const transport = new Ssh2Transport(host, fake as unknown as Client);
await transport.connect();
return { fake, transport };
}
test("connects with password auth, pins the host key, and streams exec output", async () => {
const { fake, transport } = await connectedTransport();
assert.equal(fake.connectConfig?.host, "host.example.test");
assert.equal(fake.connectConfig?.username, "builder");
assert.equal(fake.connectConfig?.password, "secret");
assert.equal(fake.connectConfig?.tryKeyboard, true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
const output: Buffer[] = [];
const result = await transport.exec("printf ok", "/srv/build", {
onData: (data) => output.push(data),
timeout: 5,
});
assert.equal(result.exitCode, 7);
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
await transport.dispose();
assert.equal(fake.ended, true);
});
test("supports password-only and keyboard-interactive authentication", async () => {
const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
await passwordOnly.transport.dispose();
const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
let answers: string[] = [];
interactive.fake.emit(
"keyboard-interactive",
"name",
"instructions",
"",
[{ prompt: "Password:" }, { prompt: "Again:" }],
(values: string[]) => { answers = values; },
);
assert.deepEqual(answers, ["secret", "secret"]);
await interactive.transport.dispose();
});
test("loads private-key authentication and forwards the passphrase", async () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
try {
const identityFile = join(root, "id_test");
writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
const host: SshHostConfig = {
...passwordHost(),
auth: { type: "private-key", identityFile, passphrase: "key-secret" },
};
const { fake, transport } = await connectedTransport(undefined, host);
assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
assert.equal(fake.connectConfig?.passphrase, "key-secret");
assert.equal(fake.connectConfig?.password, undefined);
assert.equal(fake.connectConfig?.tryKeyboard, undefined);
await transport.dispose();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const { fake, transport } = await connectedTransport(sftp);
assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
await transport.ensureReadable("/srv/app/a.txt");
await transport.ensureReadableWritable("/srv/app/a.txt");
assert.deepEqual(sftp.openCalls, [
{ path: "/srv/app/a.txt", flags: "r" },
{ path: "/srv/app/a.txt", flags: "r+" },
]);
assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
assert.equal(fake.sftpCalls, 1);
await transport.dispose();
});
test("creates parent directories and atomically renames remote writes", async () => {
const sftp = new FakeSftp(true);
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
assert.equal(sftp.directories.has("/srv"), true);
assert.equal(sftp.directories.has("/srv/app"), true);
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, true);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
const sftp = new FakeSftp(false);
sftp.directories.add("/srv");
sftp.directories.add("/srv/app");
sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
sftp.failStandardRename = true;
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, false);
assert.equal(sftp.unlinks.length, 1);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("aborts and times out commands by closing the active channel", async () => {
const abortClient = new FakeClient();
const abortChannel = new FakeChannel();
abortClient.execAction = (_command, callback) => {
abortClient.channel = abortChannel;
callback(undefined, abortChannel as ClientChannel);
};
const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
await abortTransport.connect();
const controller = new AbortController();
const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(aborted, /SSH command aborted/);
assert.deepEqual(abortChannel.signals, ["KILL"]);
assert.equal(abortChannel.closeCalls, 1);
await abortTransport.dispose();
const timeoutClient = new FakeClient();
const timeoutChannel = new FakeChannel();
timeoutClient.execAction = (_command, callback) => {
timeoutClient.channel = timeoutChannel;
callback(undefined, timeoutChannel as ClientChannel);
};
const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
await timeoutTransport.connect();
await assert.rejects(
timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
/timed out after 0.01s/,
);
assert.deepEqual(timeoutChannel.signals, ["KILL"]);
assert.equal(timeoutChannel.closeCalls, 1);
await timeoutTransport.dispose();
});
test("fails closed after the SSH connection closes", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/a.txt", Buffer.from("a"));
const { fake, transport } = await connectedTransport(sftp);
fake.emit("close");
await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
await transport.dispose();
});
test("probes and fingerprints a host key without authenticating", async () => {
const fake = new FakeClient();
const key = fixtureKey("probe");
fake.connectAction = (config, client) => {
assert.equal(config.host, "probe.example.test");
assert.equal(config.port, 2200);
assert.equal(config.username, "probe-user");
assert.equal(config.hostVerifier?.(key), false);
client.emit("close");
};
const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
assert.deepEqual(result, fingerprintHostKey(key));
});
test("rejects host-key probing errors before a key is observed", async () => {
const fake = new FakeClient();
fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
/network unavailable/,
);
assert.equal(fake.destroyed, true);
});
test("times out host-key probing when the server never responds", async () => {
const fake = new FakeClient();
fake.connectAction = () => {};
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
/timed out while obtaining SSH host key/,
);
assert.equal(fake.destroyed, true);
assert.equal(fake.connectConfig?.readyTimeout, 5);
});