import { EventEmitter } from 'node:events'; import { KaosFileExistsError, KaosValueError } from '#/errors'; import { KaosConnectionError, KaosFileNotFoundError, KaosPermissionError, KaosSSHError, SSHKaos, } from '#/ssh'; import type { StatResult } from '#/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from 'vitest'; // Environment variable configuration for SSH connection const SSH_SMOKE = process.env['KAOS_SSH_SMOKE'] === '1'; const SSH_HOST = process.env['KAOS_SSH_HOST'] ?? '127.0.0.1'; const SSH_PORT = Number(process.env['KAOS_SSH_PORT'] ?? '22'); const SSH_USERNAME = process.env['KAOS_SSH_USERNAME']; const SSH_PASSWORD = process.env['KAOS_SSH_PASSWORD']; const SSH_KEY_PATHS = process.env['KAOS_SSH_KEY_PATHS']?.split(',').filter(Boolean); const SSH_KEY_CONTENTS = process.env['KAOS_SSH_KEY_CONTENTS']?.split('|||').filter(Boolean); // S_IFMT mask and file type constants const S_IFMT = 0o170000; const S_IFDIR = 0o040000; const S_IFREG = 0o100000; async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk as Buffer)); } return Buffer.concat(chunks); } // Explicit opt-in smoke: set KAOS_SSH_SMOKE=1 plus SSH credentials. describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => { let sshKaos: SSHKaos; let remoteBase = ''; beforeAll(async () => { if (SSH_USERNAME === undefined) { throw new Error('KAOS_SSH_SMOKE=1 requires KAOS_SSH_USERNAME'); } // Dynamic import to avoid compilation errors when ssh2 is not available const { SSHKaos: SSHKaosClass } = await import('#/ssh'); sshKaos = await SSHKaosClass.create({ host: SSH_HOST, port: SSH_PORT, username: SSH_USERNAME, password: SSH_PASSWORD, keyPaths: SSH_KEY_PATHS, keyContents: SSH_KEY_CONTENTS, }); }); beforeEach(async () => { // Create an isolated remote directory for each test const uuid = Math.random().toString(36).slice(2); remoteBase = `${sshKaos.gethome()}/.kaos_test_${process.pid}_${uuid}`; await sshKaos.mkdir(remoteBase, { parents: true, existOk: true }); await sshKaos.chdir(remoteBase); }); afterEach(async () => { // Cleanup the remote directory best-effort, but always restore cwd. if (remoteBase.length > 0) { try { const proc = await sshKaos.exec('rm', '-rf', remoteBase); await proc.wait(); } finally { remoteBase = ''; await sshKaos.chdir(sshKaos.gethome()); } } }); afterAll(async () => { if (sshKaos) await sshKaos.close(); }); test('pathClass, home, and cwd', () => { const home = sshKaos.gethome(); const cwd = sshKaos.getcwd(); expect(sshKaos.pathClass()).toBe('posix'); expect(home.length).toBeGreaterThan(0); expect(cwd.length).toBeGreaterThan(0); // Home should be absolute expect(home.startsWith('/')).toBe(true); // cwd should be absolute expect(cwd.startsWith('/')).toBe(true); }); test('cwd defaults to home immediately after connect', () => { // A freshly connected session sets cwd to the remote home directory, // so the two must be string-equal before any chdir. expect(sshKaos.getcwd()).toBe(sshKaos.gethome()); }); test('chdir updates real path', async () => { await sshKaos.chdir(remoteBase); expect(sshKaos.getcwd()).toBe(remoteBase); await sshKaos.mkdir(remoteBase + '/child', { existOk: true }); await sshKaos.chdir('child'); expect(sshKaos.getcwd()).toBe(remoteBase + '/child'); await sshKaos.chdir('..'); expect(sshKaos.getcwd()).toBe(remoteBase); }); test('exec respects cwd', async () => { await sshKaos.chdir(remoteBase); const proc = await sshKaos.exec('pwd'); const out = (await streamToBuffer(proc.stdout)).toString().trim(); const code = await proc.wait(); expect(code).toBe(0); expect(out).toBe(remoteBase); }); test('exec wait before read', async () => { const proc = await sshKaos.exec('echo', 'output'); const exitCode = await proc.wait(); const output = (await streamToBuffer(proc.stdout)).toString().trim(); expect(exitCode).toBe(0); expect(output).toBe('output'); }); test('mkdir respects existOk', async () => { const nestedDir = remoteBase + '/deep/level'; await sshKaos.mkdir(nestedDir, { parents: true, existOk: false }); // Python test_mkdir_respects_exist_ok pins `pytest.raises(FileExistsError)` // — match that strength by asserting the specific KaosFileExistsError class // rather than any throwable. await expect(sshKaos.mkdir(nestedDir, { existOk: false })).rejects.toBeInstanceOf( KaosFileExistsError, ); await sshKaos.mkdir(nestedDir, { parents: true, existOk: true }); }); test('stat reports directory and file metadata', async () => { const dirStat = await sshKaos.stat(remoteBase, { followSymlinks: false }); expect((dirStat.stMode & S_IFMT) === S_IFDIR).toBe(true); const filePath = remoteBase + '/payload.txt'; const payload = 'metadata'; await sshKaos.writeText(filePath, payload); const fileStat = await sshKaos.stat(filePath); expect((fileStat.stMode & S_IFMT) === S_IFREG).toBe(true); expect(fileStat.stSize).toBe(payload.length); expect(fileStat.stNlink).toBeGreaterThanOrEqual(0); }); test('file roundtrip via SSH', async () => { await sshKaos.chdir(remoteBase); const textPath = remoteBase + '/text.txt'; const bytesPath = remoteBase + '/blob.bin'; const textPayload = 'Hello SSH\n'; const appended = 'More data\n'; const written = await sshKaos.writeText(textPath, textPayload); expect(written).toBe(textPayload.length); const appendedLen = await sshKaos.writeText(textPath, appended, { mode: 'a' }); expect(appendedLen).toBe(appended.length); const fullText = await sshKaos.readText(textPath); expect(fullText).toBe(textPayload + appended); const lines: string[] = []; for await (const line of sshKaos.readLines(textPath)) { lines.push(line); } expect(lines).toEqual(['Hello SSH', 'More data']); const bytesPayload = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); const bytesWritten = await sshKaos.writeBytes(bytesPath, bytesPayload); expect(bytesWritten).toBe(bytesPayload.length); const roundtrip = await sshKaos.readBytes(bytesPath); expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0); expect(sshKaos.getcwd()).toBe(remoteBase); }); test('iterdir lists child entries', async () => { await sshKaos.writeText(remoteBase + '/file1.txt', '1'); await sshKaos.writeText(remoteBase + '/file2.log', '2'); await sshKaos.mkdir(remoteBase + '/subdir', { existOk: true }); const entries: string[] = []; for await (const entry of sshKaos.iterdir(remoteBase)) { entries.push(entry); } const names = new Set(entries.map((e) => e.split('/').pop()!)); expect(names).toEqual(new Set(['file1.txt', 'file2.log', 'subdir'])); }); test('glob is case sensitive', async () => { await sshKaos.writeText(remoteBase + '/file.log', 'lowercase'); await sshKaos.writeText(remoteBase + '/FILE.LOG', 'uppercase'); const matches = new Set(); for await (const path of sshKaos.glob(remoteBase, '*.log')) { matches.add(path); } expect(matches.has(remoteBase + '/file.log')).toBe(true); expect(matches.has(remoteBase + '/FILE.LOG')).toBe(false); await expect(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of sshKaos.glob(remoteBase, '*.log', { caseSensitive: false })) { // should throw before yielding } }).rejects.toThrow('Case insensitive glob is not supported'); }); test('exec streams stdout and stderr', async () => { const proc = await sshKaos.exec('sh', '-c', "printf 'out\\n' && printf 'err\\n' 1>&2"); const [stdoutData, stderrData] = await Promise.all([ streamToBuffer(proc.stdout), streamToBuffer(proc.stderr), ]); const exitCode = await proc.wait(); expect(exitCode).toBe(0); expect(proc.exitCode).toBe(0); expect(stdoutData.toString().trim()).toBe('out'); expect(stderrData.toString().trim()).toBe('err'); }); // NOTE: these execWithEnv tests require the remote sshd to accept the // injected variable names via its `AcceptEnv` directive (or an equivalent // mechanism). Stock OpenSSH only whitelists LANG/LC_*; if the test server // is not configured to accept KAOS_TEST_*, these tests will fail — which // is exactly the signal we want (it reveals the silent env-drop bug that // the Python version has). test('execWithEnv delivers a single env var to the remote process', async () => { const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_MARKER}"'], { KAOS_TEST_MARKER: 'beacon42', }); const out = (await streamToBuffer(proc.stdout)).toString(); const code = await proc.wait(); expect(code).toBe(0); expect(out).toBe('beacon42'); }); test('execWithEnv delivers multiple env vars', async () => { const proc = await sshKaos.execWithEnv( ['sh', '-c', 'printf "%s|%s" "${KAOS_TEST_A}" "${KAOS_TEST_B}"'], { KAOS_TEST_A: 'hello', KAOS_TEST_B: 'world' }, ); const out = (await streamToBuffer(proc.stdout)).toString(); const code = await proc.wait(); expect(code).toBe(0); expect(out).toBe('hello|world'); }); test('execWithEnv preserves values with shell metacharacters', async () => { // Single quotes, dollar signs, backticks, pipes, ampersands, redirects, // double quotes, and a backslash — anything an unsafe impl might mangle. const value = `it's $HOME \`id\`; | & < > " \\`; const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_VALUE}"'], { KAOS_TEST_VALUE: value, }); const out = (await streamToBuffer(proc.stdout)).toString(); const code = await proc.wait(); expect(code).toBe(0); expect(out).toBe(value); }); test('exec rejects empty command', async () => { await expect((sshKaos.exec as (...args: string[]) => Promise)()).rejects.toThrow(); }); test('process kill updates returncode', async () => { const proc = await sshKaos.exec('sh', '-c', 'echo ready; sleep 30'); // Read the first line to know the process has started const firstChunk = await new Promise((resolve) => { proc.stdout.once('data', (chunk: Buffer) => { resolve(chunk); }); }); expect(firstChunk.toString().trim()).toBe('ready'); expect(proc.exitCode).toBeNull(); await proc.kill(); const exitCode = await proc.wait(); expect(exitCode).not.toBe(0); expect(proc.exitCode).toBe(exitCode); expect(proc.pid).toBe(-1); }); }); // These tests don't need a live SSH connection — they exercise the // argument-validation guards that run before any network I/O. We invoke the // methods through the prototype so no real instance is constructed. describe('SSHKaos argument validation', () => { it('exec() throws with the correct class name when args is empty', () => { const fakeThis = {} as SSHKaos; expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(KaosValueError); expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(/SSHKaos\.exec\(\)/); }); it('execWithEnv() throws with the correct class name when args is empty', () => { const fakeThis = {} as SSHKaos; expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow(KaosValueError); expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow( /SSHKaos\.execWithEnv\(\)/, ); }); // glob() is an async generator, so the caseSensitive=false guard fires on // the first pull rather than at call-time. We verify both the error class // (KaosValueError) and the fact that it rejects before touching SFTP. it('glob(caseSensitive: false) rejects with KaosValueError', async () => { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _cwd: string; _sftp: unknown }; internal._cwd = '/tmp'; internal._sftp = {}; const gen = instance.glob('/some/path', '*', { caseSensitive: false }); await expect(gen.next()).rejects.toBeInstanceOf(KaosValueError); }); }); // chdir should refuse to treat a regular file (or anything that isn't a // directory) as the new working directory. Without this guard, `sftp.realpath` // happily returns file paths and later relative reads/writes/execs would // resolve against a file — silently wrong. We exercise this by constructing // a fake SFTP that returns a file stat so the test needs no live SSH. describe('SSHKaos.chdir directory validation', () => { // Minimal SFTPWrapper stub with only the methods chdir needs. function makeFakeSftp(target: string, isDir: boolean): unknown { return { realpath(_path: string, cb: (err: Error | null | undefined, absPath: string) => void): void { cb(null, target); }, stat( _path: string, cb: (err: Error | null | undefined, stats: Record) => void, ): void { cb(null, { mode: isDir ? 0o040755 : 0o100644, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0, isDirectory: () => isDir, isFile: () => !isDir, isSymbolicLink: () => false, isSocket: () => false, isCharacterDevice: () => false, isBlockDevice: () => false, isFIFO: () => false, }); }, }; } function makeFakeInstance(sftp: unknown, cwd: string): SSHKaos { // Bypass the real constructor (which requires a live ssh2 client) and // populate just the private fields that chdir touches. const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string }; internal._sftp = sftp; internal._cwd = cwd; return instance; } it('rejects a target that resolves to a regular file', async () => { const target = '/tmp/not-a-dir.txt'; const sftp = makeFakeSftp(target, /*isDir=*/ false); const kaos = makeFakeInstance(sftp, '/tmp'); await expect(kaos.chdir(target)).rejects.toThrow(KaosValueError); await expect(kaos.chdir(target)).rejects.toThrow(/not a directory/); // cwd must remain unchanged on failure. expect(kaos.getcwd()).toBe('/tmp'); }); it('accepts a target that resolves to a directory', async () => { const target = '/tmp/real-dir'; const sftp = makeFakeSftp(target, /*isDir=*/ true); const kaos = makeFakeInstance(sftp, '/tmp'); await kaos.chdir(target); expect(kaos.getcwd()).toBe(target); }); }); // These tests pin the SFTPError → KaosError mapping contract. They use a // fake SFTPWrapper that invokes callbacks with errors carrying the standard // SFTP status codes (NO_SUCH_FILE=2, PERMISSION_DENIED=3), so they run in // CI without needing a live SSH connection. // // The mapping lives in the promisified SFTP helpers in ssh.ts, so every // SSHKaos method that touches SFTP automatically throws a KaosSSHError // subclass (KaosFileNotFoundError / KaosPermissionError / …) instead of // the raw ssh2 error. describe('SSHKaos SFTP error mapping', () => { const NO_SUCH_FILE = 2; const PERMISSION_DENIED = 3; interface FailingMethods { stat?: boolean; lstat?: boolean; readFile?: boolean; writeFile?: boolean; appendFile?: boolean; mkdir?: boolean; readdir?: boolean; } function makeSftpError(errorCode: number): Error { const err = new Error('simulated SFTP error'); (err as unknown as { code: number }).code = errorCode; return err; } // Minimal SFTPWrapper stub. For each I/O method, when `failing[method]` is // true the callback is invoked with an error carrying `code`; otherwise // a harmless default is returned. Only the methods that SSHKaos actually // calls need to be stubbed. function makeFakeSftp(errorCode: number, failing: FailingMethods): unknown { const dirStats = { mode: 0o040755, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0, isDirectory: (): boolean => true, isFile: (): boolean => false, isSymbolicLink: (): boolean => false, isSocket: (): boolean => false, isCharacterDevice: (): boolean => false, isBlockDevice: (): boolean => false, isFIFO: (): boolean => false, }; return { realpath(path: string, cb: (err: Error | null, abs: string) => void): void { cb(null, path); }, stat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { if (failing.stat === true) { cb(makeSftpError(errorCode)); return; } cb(null, dirStats); }, lstat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { if (failing.lstat === true) { cb(makeSftpError(errorCode)); return; } cb(null, dirStats); }, readFile(_path: string, cb: (err: Error | null, data?: Buffer) => void): void { if (failing.readFile === true) { cb(makeSftpError(errorCode)); return; } cb(null, Buffer.alloc(0)); }, writeFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { if (failing.writeFile === true) { cb(makeSftpError(errorCode)); return; } cb(null); }, appendFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { if (failing.appendFile === true) { cb(makeSftpError(errorCode)); return; } cb(null); }, mkdir(_path: string, cb: (err: Error | null) => void): void { if (failing.mkdir === true) { cb(makeSftpError(errorCode)); return; } cb(null); }, readdir(_path: string, cb: (err: Error | null, list?: unknown[]) => void): void { if (failing.readdir === true) { cb(makeSftpError(errorCode)); return; } cb(null, []); }, // exists() always reports the file is absent so plain mkdir takes the // create path (where `failing.mkdir` decides the outcome). exists(_path: string, cb: (exists: boolean) => void): void { cb(false); }, }; } function makeFakeKaos(sftp: unknown): SSHKaos { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; internal._sftp = sftp; internal._cwd = '/'; internal._home = '/'; return instance; } // ── stat(): the one method that already wraps errors. ──────────────── it('stat() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { stat: true })); await expect(kaos.stat('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); }); it('stat() maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { stat: true })); await expect(kaos.stat('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); }); it('stat({ followSymlinks: false }) wraps lstat errors the same way', async () => { const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { lstat: true })); await expect(kaos.stat('/missing', { followSymlinks: false })).rejects.toBeInstanceOf( KaosFileNotFoundError, ); }); it('stat() wraps unmapped failures as the base KaosSSHError', async () => { // FAILURE=4 is not specifically mapped → generic KaosSSHError. const kaos = makeFakeKaos(makeFakeSftp(4, { stat: true })); await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosSSHError); }); it('stat() maps NO_CONNECTION → KaosConnectionError', async () => { // SFTP STATUS_CODE.NO_CONNECTION = 6 const kaos = makeFakeKaos(makeFakeSftp(6, { stat: true })); await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); }); it('stat() maps CONNECTION_LOST → KaosConnectionError', async () => { // SFTP STATUS_CODE.CONNECTION_LOST = 7 const kaos = makeFakeKaos(makeFakeSftp(7, { stat: true })); await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); }); it('stat() wraps errors without a numeric code as generic KaosSSHError', async () => { // An error object whose `.code` is not a number (or absent entirely) // must still be wrapped — the mapSftpError fallback should kick in. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, stat(_p: string, cb: (err: Error | null) => void): void { // Error with no .code field cb(new Error('no code here')); }, }; const kaos = makeFakeKaos(sftp); const err = await kaos.stat('/x').catch((error: unknown) => error); expect(err).toBeInstanceOf(KaosSSHError); expect((err as KaosSSHError).message).toContain('no code here'); }); it('stat() wraps non-Error rejections by stringifying them', async () => { // The helper's getErrorMessage fallback handles non-Error values by // calling String(error). Reject with a plain string and verify the // wrap still produces a KaosSSHError with the string in the message. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, stat(_p: string, cb: (err: unknown) => void): void { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors cb('raw-string-rejection'); }, }; const kaos = makeFakeKaos(sftp); const err = await kaos.stat('/x').catch((error: unknown) => error); expect(err).toBeInstanceOf(KaosSSHError); expect((err as KaosSSHError).message).toContain('raw-string-rejection'); }); it('chdir() propagates a realpath failure as a KaosSSHError', async () => { // sftpRealpath now runs through mapSftpError too — pin that contract. const sftp = { realpath(_p: string, cb: (err: Error) => void): void { const err = new Error('realpath broke'); (err as unknown as { code: number }).code = 2; cb(err); }, stat(_p: string, _cb: unknown): void { throw new Error('should not be called'); }, }; const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string }; internal._sftp = sftp; internal._cwd = '/'; await expect(instance.chdir('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); }); // ── Other I/O methods: mapping is pushed into the promisified helpers // in ssh.ts so every method gets the same wrapping for free. ───────── it('readText() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); await expect(kaos.readText('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); }); it('readBytes() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); await expect(kaos.readBytes('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); }); it('writeText() maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); await expect(kaos.writeText('/forbidden', 'data')).rejects.toBeInstanceOf(KaosPermissionError); }); it('writeText(append) maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { appendFile: true })); await expect(kaos.writeText('/forbidden', 'data', { mode: 'a' })).rejects.toBeInstanceOf( KaosPermissionError, ); }); it('writeBytes() maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); await expect(kaos.writeBytes('/forbidden', Buffer.from('x'))).rejects.toBeInstanceOf( KaosPermissionError, ); }); it('mkdir() maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { mkdir: true })); await expect(kaos.mkdir('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); }); it('iterdir() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readdir: true })); const gen = kaos.iterdir('/missing'); await expect(gen.next()).rejects.toBeInstanceOf(KaosFileNotFoundError); }); }); // These tests exercise the pure command-building logic behind execWithEnv // without needing a live SSH connection. The actual end-to-end delivery of // env vars is validated by the smoke tests above when KAOS_SSH_SMOKE=1. describe('SSHKaos._buildExecCommand', () => { // Bracket access so we can reach the private static helper from tests // without changing its visibility in the public API. const build = ( SSHKaos as unknown as { _buildExecCommand: (args: string[], cwd: string, env?: Record) => string; } )._buildExecCommand; it('cd prefix + bare command when no env is supplied', () => { expect(build(['ls', '-la'], '/home/user')).toBe('cd /home/user && ls -la'); }); it('injects inline assignments before the command', () => { expect(build(['echo', 'x'], '/home/user', { FOO: 'bar' })).toBe( 'cd /home/user && FOO=bar echo x', ); }); it('injects multiple env vars in declaration order', () => { const out = build(['sh', '-c', 'echo $A $B'], '/home/user', { A: '1', B: '2' }); expect(out).toBe("cd /home/user && A=1 B=2 sh -c 'echo $A $B'"); }); it('quotes values containing shell metacharacters', () => { // Single quote in value → shellQuote escapes via the '"'"' trick. expect(build(['cmd'], '/home/user', { V: "it's" })).toBe(`cd /home/user && V='it'"'"'s' cmd`); // Dollar sign, backticks, pipe, ampersand → single-quoted wholesale. expect(build(['cmd'], '/home/user', { V: '$HOME `id` | &' })).toBe( `cd /home/user && V='$HOME \`id\` | &' cmd`, ); }); it('quotes an empty value as empty single quotes', () => { expect(build(['cmd'], '/home/user', { V: '' })).toBe("cd /home/user && V='' cmd"); }); it('rejects env var names that are not valid POSIX identifiers', () => { expect(() => build(['cmd'], '/home/user', { '1BAD': 'x' })).toThrow(KaosValueError); expect(() => build(['cmd'], '/home/user', { 'WITH SPACE': 'x' })).toThrow(KaosValueError); expect(() => build(['cmd'], '/home/user', { 'WITH=EQUALS': 'x' })).toThrow(KaosValueError); expect(() => build(['cmd'], '/home/user', { '': 'x' })).toThrow(KaosValueError); }); it('accepts underscored and mixed-case identifiers', () => { expect(build(['cmd'], '/home/user', { _UNDER: '1', camelCase: '2' })).toBe( 'cd /home/user && _UNDER=1 camelCase=2 cmd', ); }); it('skips the cd prefix when cwd is the empty string', () => { expect(build(['cmd', 'arg'], '', { FOO: 'bar' })).toBe('FOO=bar cmd arg'); }); it('quotes cwd paths with spaces and special characters', () => { expect(build(['cmd'], "/home/u ser's dir")).toBe(`cd '/home/u ser'"'"'s dir' && cmd`); }); it('omits the assignment section entirely when env is an empty object', () => { // Matches the behavior of plain exec() — no leading space, no KEY=... expect(build(['cmd', 'arg'], '/home/user', {})).toBe('cd /home/user && cmd arg'); }); }); // These tests drive the SSHKaos read/stat/glob/iterdir happy paths via // fake SFTP wrappers, so they run in CI without a live SSH server. Without // them the smoke block is the only route to those code paths, which means // CI only sees the error branches. describe('SSHKaos mock success paths', () => { interface TreeNode { type: 'dir' | 'file'; children?: Record; content?: Buffer; // Permission bits only — forces buildStMode to derive the type bits // from the is* helpers (the "mode without type bits" branch) when true. stripTypeBits?: boolean; } function makeStats(node: TreeNode): unknown { const isDir = node.type === 'dir'; // Either include type bits (0o040000/0o100000) or strip them to force // the buildStMode helper to derive them via isDirectory()/isFile(). const baseMode = isDir ? 0o040755 : 0o100644; const mode = node.stripTypeBits === true ? baseMode & 0o7777 : baseMode; return { mode, size: node.content ? node.content.length : 0, uid: 1000, gid: 1000, atime: 100, mtime: 200, isDirectory: () => isDir, isFile: () => !isDir, isSymbolicLink: () => false, isSocket: () => false, isCharacterDevice: () => false, isBlockDevice: () => false, isFIFO: () => false, }; } function lookup(root: TreeNode, path: string): TreeNode | undefined { if (path === '/') return root; const parts = path.split('/').filter(Boolean); let current: TreeNode | undefined = root; for (const part of parts) { if (!current?.children?.[part]) return undefined; current = current.children[part]; } return current; } // Fake SFTP that exposes a tree and implements the handful of callbacks // SSHKaos actually calls. Anything not needed is left unimplemented. function makeTreeSftp(root: TreeNode): unknown { return { realpath(path: string, cb: (err: Error | null, abs: string) => void): void { cb(null, path); }, stat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { const node = lookup(root, path); if (!node) { const err = new Error(`no such file: ${path}`); (err as unknown as { code: number }).code = 2; cb(err); return; } cb(null, makeStats(node)); }, lstat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { const node = lookup(root, path); if (!node) { const err = new Error(`no such file: ${path}`); (err as unknown as { code: number }).code = 2; cb(err); return; } cb(null, makeStats(node)); }, readdir(path: string, cb: (err: Error | null, list?: unknown[]) => void): void { const node = lookup(root, path); if (!node || node.type !== 'dir' || !node.children) { const err = new Error(`not a directory: ${path}`); (err as unknown as { code: number }).code = 2; cb(err); return; } cb( null, Object.entries(node.children).map(([filename, child]) => ({ filename, attrs: makeStats(child), })), ); }, readFile(path: string, cb: (err: Error | null, data?: Buffer) => void): void { const node = lookup(root, path); if (!node || node.type !== 'file') { const err = new Error(`no such file: ${path}`); (err as unknown as { code: number }).code = 2; cb(err); return; } cb(null, node.content ?? Buffer.alloc(0)); }, }; } function makeFakeKaos(sftp: unknown, cwd = '/'): SSHKaos { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; internal._sftp = sftp; internal._cwd = cwd; internal._home = '/home/tester'; return instance; } // ── path helpers ──────────────────────────────────────────────────── it('normpath delegates to posix.normalize', () => { // No I/O — just the pure path function. Pins that normpath collapses // `..` segments. const kaos = makeFakeKaos(makeTreeSftp({ type: 'dir', children: {} })); expect(kaos.normpath('/a/b/../c')).toBe('/a/c'); }); // ── stat + buildStMode variants ───────────────────────────────────── it('stat() returns a StatResult with file-type bits preserved', async () => { const root: TreeNode = { type: 'dir', children: { 'file.txt': { type: 'file', content: Buffer.from('hi') }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const fileStat: StatResult = await kaos.stat('/file.txt'); expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); expect(fileStat.stSize).toBe(2); expect(fileStat.stUid).toBe(1000); }); it('stat() derives type bits from is* helpers when mode lacks them', async () => { // mode = 0o755 has zero in the S_IFMT bits, so buildStMode must fall // back to isDirectory()/isFile() to fill them in. const root: TreeNode = { type: 'dir', stripTypeBits: true, children: { 'bare.txt': { type: 'file', stripTypeBits: true, content: Buffer.from('x') }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const dirStat = await kaos.stat('/'); expect((dirStat.stMode & 0o170000) === 0o040000).toBe(true); const fileStat = await kaos.stat('/bare.txt'); expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); }); it('stat({ followSymlinks: false }) uses the lstat branch', async () => { // No actual symlinks — just verify the alternate code path is taken // (lstat callback is wired up, it returns stats without throwing). const root: TreeNode = { type: 'dir', children: { 'a.txt': { type: 'file', content: Buffer.from('hi') } }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const result = await kaos.stat('/a.txt', { followSymlinks: false }); expect(result.stSize).toBe(2); }); // ── iterdir ──────────────────────────────────────────────────────── it('iterdir yields directory entries', async () => { const root: TreeNode = { type: 'dir', children: { tree: { type: 'dir', children: { 'a.txt': { type: 'file', content: Buffer.from('a') }, 'b.txt': { type: 'file', content: Buffer.from('b') }, sub: { type: 'dir', children: {} }, }, }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const entries: string[] = []; for await (const entry of kaos.iterdir('/tree')) { entries.push(entry); } expect(new Set(entries)).toEqual(new Set(['/tree/a.txt', '/tree/b.txt', '/tree/sub'])); }); // ── glob ──────────────────────────────────────────────────────────── it('glob matches flat patterns against the directory', async () => { const root: TreeNode = { type: 'dir', children: { tree: { type: 'dir', children: { 'root.txt': { type: 'file', content: Buffer.from('r') }, 'root.log': { type: 'file', content: Buffer.from('l') }, }, }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const matches: string[] = []; for await (const m of kaos.glob('/tree', '*.txt')) { matches.push(m); } expect(matches).toEqual(['/tree/root.txt']); }); it('glob recurses with **/pattern to match nested files', async () => { const root: TreeNode = { type: 'dir', children: { tree: { type: 'dir', children: { 'root.txt': { type: 'file', content: Buffer.from('r') }, sub: { type: 'dir', children: { 'nested.txt': { type: 'file', content: Buffer.from('n') }, deep: { type: 'dir', children: { 'deeper.txt': { type: 'file', content: Buffer.from('d') }, }, }, }, }, }, }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const matches: string[] = []; for await (const m of kaos.glob('/tree', '**/*.txt')) { matches.push(m); } const names = new Set(matches); expect(names).toEqual( new Set(['/tree/root.txt', '/tree/sub/nested.txt', '/tree/sub/deep/deeper.txt']), ); // Pin the de-dup invariant — no single file should appear twice. expect(matches.length).toBe(new Set(matches).size); }); it('glob with bare ** yields basePath and every nested entry', async () => { const root: TreeNode = { type: 'dir', children: { tree: { type: 'dir', children: { 'root.txt': { type: 'file', content: Buffer.from('r') }, sub: { type: 'dir', children: { 'nested.txt': { type: 'file', content: Buffer.from('n') }, }, }, }, }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const matches: string[] = []; for await (const m of kaos.glob('/tree', '**')) { matches.push(m); } const set = new Set(matches); expect(set.has('/tree')).toBe(true); expect(set.has('/tree/root.txt')).toBe(true); expect(set.has('/tree/sub')).toBe(true); expect(set.has('/tree/sub/nested.txt')).toBe(true); }); it('glob with a nested literal path recurses only into the named dir', async () => { // Pattern `sub/*.txt` → literal `sub` segment, then `*.txt` inside it. // Exercises the non-`**` recursive branch. const root: TreeNode = { type: 'dir', children: { tree: { type: 'dir', children: { 'root.txt': { type: 'file', content: Buffer.from('r') }, sub: { type: 'dir', children: { 'a.txt': { type: 'file', content: Buffer.from('a') }, 'b.log': { type: 'file', content: Buffer.from('b') }, }, }, }, }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const matches: string[] = []; for await (const m of kaos.glob('/tree', 'sub/*.txt')) { matches.push(m); } expect(matches).toEqual(['/tree/sub/a.txt']); }); it('glob silently skips unreadable directories', async () => { // If readdir() fails the generator should return without throwing — // makes glob tolerant of permission-limited subtrees. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { const err = new Error('permission denied'); (err as unknown as { code: number }).code = 3; cb(err); }, }; const kaos = makeFakeKaos(sftp); const matches: string[] = []; for await (const m of kaos.glob('/locked', '*.txt')) { matches.push(m); } expect(matches).toEqual([]); }); // ── readLines empty-file early return ───────────────────────────── it('readLines yields nothing for an empty file', async () => { // `readText` returns an empty string, so the generator must take the // early-return branch instead of walking `splitlines()`. const root: TreeNode = { type: 'dir', children: { 'empty.txt': { type: 'file', content: Buffer.alloc(0) } }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const lines: string[] = []; for await (const line of kaos.readLines('/empty.txt')) { lines.push(line); } expect(lines).toEqual([]); }); it('readText preserves valid U+FFFD while ignoring invalid utf-8 bytes', async () => { const data = Buffer.concat([ Buffer.from('A\uFFFDB', 'utf-8'), Buffer.from([0xff]), Buffer.from('C', 'utf-8'), ]); const root: TreeNode = { type: 'dir', children: { 'mixed.txt': { type: 'file', content: data } }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); await expect(kaos.readText('/mixed.txt', { errors: 'ignore' })).resolves.toBe('A\uFFFDBC'); }); // ── . / .. filter coverage ──────────────────────────────────────── it('iterdir at root "/" does not produce double-slash paths', async () => { // Regression: `basePath + '/' + entry.filename` produced `//foo` when // basePath was the filesystem root. Now uses `posix.join` to collapse. const root: TreeNode = { type: 'dir', children: { 'a.txt': { type: 'file', content: Buffer.from('a') }, sub: { type: 'dir', children: {} }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root), '/'); const entries: string[] = []; for await (const entry of kaos.iterdir('/')) { entries.push(entry); } expect(new Set(entries)).toEqual(new Set(['/a.txt', '/sub'])); expect(entries.every((e) => !e.includes('//'))).toBe(true); }); it('glob at root "/" does not produce double-slash paths', async () => { const root: TreeNode = { type: 'dir', children: { 'file.txt': { type: 'file', content: Buffer.from('x') }, }, }; const kaos = makeFakeKaos(makeTreeSftp(root), '/'); const matches: string[] = []; for await (const m of kaos.glob('/', '*.txt')) { matches.push(m); } expect(matches).toEqual(['/file.txt']); expect(matches.every((p) => !p.includes('//'))).toBe(true); }); it('iterdir filters out "." and ".." entries from readdir output', async () => { // Some SFTP servers include `.` / `..` in readdir results, so the // filter in SSHKaos.iterdir must skip them unconditionally. Inject // those entries into the fake to exercise the filter branch. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { cb(null, [ { filename: '.', attrs: { isDirectory: (): boolean => true } }, { filename: '..', attrs: { isDirectory: (): boolean => true } }, { filename: 'real.txt', attrs: { isDirectory: (): boolean => false } }, ]); }, }; const kaos = makeFakeKaos(sftp, '/tree'); const entries: string[] = []; for await (const e of kaos.iterdir('/tree')) { entries.push(e); } // Only the real entry survives — the `.` / `..` are silently dropped. expect(entries).toEqual(['/tree/real.txt']); }); it('glob filters "." and ".." entries in both the ** and literal branches', async () => { // Same fake readdir in two walks: one with `**` (covers the `**` // branch filter) and one with a literal pattern (covers the non-`**` // branch filter). const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { cb(null, [ { filename: '.', attrs: { isDirectory: (): boolean => true, isFile: (): boolean => false, }, }, { filename: '..', attrs: { isDirectory: (): boolean => true, isFile: (): boolean => false, }, }, { filename: 'keeper.txt', attrs: { isDirectory: (): boolean => false, isFile: (): boolean => true, }, }, ]); }, }; const kaos = makeFakeKaos(sftp, '/tree'); const viaStar: string[] = []; for await (const m of kaos.glob('/tree', '*.txt')) viaStar.push(m); expect(viaStar).toEqual(['/tree/keeper.txt']); const viaStarStar: string[] = []; for await (const m of kaos.glob('/tree', '**')) viaStarStar.push(m); // The recursion into `.` / `..` must be skipped — we should see only // the base dir and the keeper file, never an infinite loop. expect(new Set(viaStarStar)).toEqual(new Set(['/tree', '/tree/keeper.txt'])); }); // ── glob error handling in the ** branch ────────────────────────── it('glob ** silently aborts when readdir fails', async () => { // Similar to the non-`**` readdir-error test, but forcing the code // through the `**` case's own try/catch. Passing a bare `**` pattern // takes the `currentPattern === '**'` branch, and the failing // readdir exercises the swallow-and-return path inside it. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { const err = new Error('denied'); (err as unknown as { code: number }).code = 3; cb(err); }, }; const kaos = makeFakeKaos(sftp, '/tree'); const matches: string[] = []; for await (const m of kaos.glob('/tree', '**')) { matches.push(m); } // Pattern `**` with zero-directory match yields basePath itself // BEFORE the readdir even runs, so we still see `/tree`. The // important bit is that no exception propagates out. expect(matches).toEqual(['/tree']); }); // ── readBytes ────────────────────────────────────────────────────── it('readBytes(n) returns only the first n bytes of the file', async () => { const content = Buffer.from('0123456789'); const root: TreeNode = { type: 'dir', children: { 'data.bin': { type: 'file', content } }, }; const kaos = makeFakeKaos(makeTreeSftp(root)); const full = await kaos.readBytes('/data.bin'); expect(Buffer.compare(full, content)).toBe(0); const first4 = await kaos.readBytes('/data.bin', 4); expect(first4.toString()).toBe('0123'); }); // ── execWithEnv body ─────────────────────────────────────────────── it('execWithEnv calls clientExec with the env-prefixed command', async () => { // We only exercise the _execInternal path via execWithEnv here — the // command-building detail is already covered by the _buildExecCommand // describe block. This test just pins that execWithEnv() with a valid // non-empty args array routes through the internal helper. const commands: string[] = []; const fakeClient = { exec(cmd: string, _a: unknown, _b?: unknown): void { commands.push(cmd); // Mimic a channel that never emits — we just want to observe the // command string, not wait for real I/O. throw new Error('stop'); }, }; const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _client: unknown; _cwd: string; _home: string; _envLayers: readonly Record[]; }; internal._client = fakeClient; internal._cwd = '/home/tester'; internal._home = '/home/tester'; internal._envLayers = []; await expect(instance.execWithEnv(['echo', 'hi'], { FOO: 'bar' })).rejects.toThrow('stop'); expect(commands).toHaveLength(1); expect(commands[0]).toBe('cd /home/tester && FOO=bar echo hi'); }); }); // These tests pin the non-race mkdir error branches in SSHKaos — the // "path already exists but is a file" and "parents=true with a final // directory already present under existOk=false" cases. All driven via // fake SFTP wrappers so no live server is required. describe('SSHKaos mkdir existOk edge cases', () => { function makeFakeKaos(sftp: unknown): SSHKaos { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string }; internal._sftp = sftp; internal._cwd = '/'; return instance; } function makeFileStats(): unknown { return { mode: 0o100644, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0, isDirectory: (): boolean => false, isFile: (): boolean => true, isSymbolicLink: (): boolean => false, isSocket: (): boolean => false, isCharacterDevice: (): boolean => false, isBlockDevice: (): boolean => false, isFIFO: (): boolean => false, }; } function makeDirStats(): unknown { return { mode: 0o040755, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0, isDirectory: (): boolean => true, isFile: (): boolean => false, isSymbolicLink: (): boolean => false, isSocket: (): boolean => false, isCharacterDevice: (): boolean => false, isBlockDevice: (): boolean => false, isFIFO: (): boolean => false, }; } it('mkdir (non-parents) rejects when existOk=true but path is a file', async () => { // simple mkdir branch: exists=true + existOk=true + not a directory // must throw instead of silently accepting the file collision. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, exists(_p: string, cb: (exists: boolean) => void): void { cb(true); }, stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { cb(null, makeFileStats()); }, }; const kaos = makeFakeKaos(sftp); await expect(kaos.mkdir('/existing-file', { existOk: true })).rejects.toBeInstanceOf( KaosFileExistsError, ); }); it('mkdir(parents=true) rejects when the final path exists and existOk=false', async () => { // Recursive branch: walks to the final component, finds it already // exists (as a directory, even), and since existOk=false the call // must surface a KaosFileExistsError instead of silently succeeding. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, exists(_p: string, cb: (exists: boolean) => void): void { cb(true); }, stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { cb(null, makeDirStats()); }, }; const kaos = makeFakeKaos(sftp); await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: false })).rejects.toBeInstanceOf( KaosFileExistsError, ); }); it('mkdir(parents=true) rejects when an intermediate path is a regular file', async () => { // Recursive branch: during the walk, an intermediate component // already exists but is a regular file (not a directory). That must // be a hard failure regardless of existOk, because the next sftpMkdir // would otherwise fail with a confusing ENOTDIR. const sftp = { realpath(p: string, cb: (err: Error | null, abs: string) => void): void { cb(null, p); }, exists(_p: string, cb: (exists: boolean) => void): void { cb(true); }, stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { cb(null, makeFileStats()); }, }; const kaos = makeFakeKaos(sftp); await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: true })).rejects.toBeInstanceOf( KaosFileExistsError, ); }); }); describe('SSHKaos.close lifecycle', () => { class FakeClient extends EventEmitter { closed = false; end(): void { queueMicrotask(() => { this.closed = true; this.emit('close'); }); } exec( _command: string, optionsOrCallback: | ((err: Error | undefined, channel: never) => void) | Record, maybeCallback?: (err: Error | undefined, channel: never) => void, ): void { const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; if (callback === undefined) { return; } if (this.closed) { callback(new Error('channel closed'), undefined as never); return; } callback(undefined, undefined as never); } } function createCloseableKaos(): SSHKaos { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internals = instance as unknown as { _client: FakeClient; _cwd: string; _home: string; _sftp: { end(): void }; _envLayers: readonly Record[]; }; internals._client = new FakeClient(); internals._cwd = '/tmp'; internals._home = '/tmp'; internals._envLayers = []; internals._sftp = { end(): void { // no-op }, }; return instance; } it('awaits the close event before allowing follow-up execs to observe the closed state', async () => { const kaos = createCloseableKaos(); await kaos.close(); await expect(kaos.exec('pwd')).rejects.toThrow(/channel closed/); }); });