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(); readonly directories = new Set(["/"]); 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((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); });