diff --git a/src/__tests__/main/utils/remote-fs.test.ts b/src/__tests__/main/utils/remote-fs.test.ts new file mode 100644 index 00000000..fee16fbb --- /dev/null +++ b/src/__tests__/main/utils/remote-fs.test.ts @@ -0,0 +1,662 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + readDirRemote, + readFileRemote, + statRemote, + directorySizeRemote, + writeFileRemote, + existsRemote, + mkdirRemote, + type RemoteFsDeps, +} from '../../../main/utils/remote-fs'; +import type { SshRemoteConfig } from '../../../shared/types'; +import type { ExecResult } from '../../../main/utils/execFile'; + +describe('remote-fs', () => { + // Base SSH config for testing + const baseConfig: SshRemoteConfig = { + id: 'test-remote-1', + name: 'Test Remote', + host: 'dev.example.com', + port: 22, + username: 'testuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + }; + + // Create mock dependencies + function createMockDeps(execResult: ExecResult): RemoteFsDeps { + return { + execSsh: vi.fn().mockResolvedValue(execResult), + buildSshArgs: vi.fn().mockReturnValue([ + '-i', '/home/user/.ssh/id_ed25519', + '-o', 'BatchMode=yes', + '-p', '22', + 'testuser@dev.example.com', + ]), + }; + } + + describe('readDirRemote', () => { + it('parses ls output correctly for regular files and directories', async () => { + const deps = createMockDeps({ + stdout: 'file1.txt\nfile2.js\nsrc/\nnode_modules/\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/home/user/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'file1.txt', isDirectory: false, isSymlink: false }, + { name: 'file2.js', isDirectory: false, isSymlink: false }, + { name: 'src', isDirectory: true, isSymlink: false }, + { name: 'node_modules', isDirectory: true, isSymlink: false }, + ]); + }); + + it('identifies symbolic links from ls -F output', async () => { + const deps = createMockDeps({ + stdout: 'link-to-dir@\nlink-to-file@\nregular.txt\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/home/user', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'link-to-dir', isDirectory: false, isSymlink: true }, + { name: 'link-to-file', isDirectory: false, isSymlink: true }, + { name: 'regular.txt', isDirectory: false, isSymlink: false }, + ]); + }); + + it('handles hidden files (from -A flag)', async () => { + const deps = createMockDeps({ + stdout: '.gitignore\n.env\npackage.json\nsrc/\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data?.map(e => e.name)).toContain('.gitignore'); + expect(result.data?.map(e => e.name)).toContain('.env'); + }); + + it('strips executable indicator (*) from files', async () => { + const deps = createMockDeps({ + stdout: 'run.sh*\nscript.py*\ndata.txt\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/scripts', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'run.sh', isDirectory: false, isSymlink: false }, + { name: 'script.py', isDirectory: false, isSymlink: false }, + { name: 'data.txt', isDirectory: false, isSymlink: false }, + ]); + }); + + it('returns error when directory does not exist', async () => { + const deps = createMockDeps({ + stdout: '__LS_ERROR__\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/nonexistent', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found or not accessible'); + }); + + it('returns error on SSH failure', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Permission denied', + exitCode: 1, + }); + + const result = await readDirRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles empty directory', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/empty-dir', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('builds correct SSH command with escaped path', async () => { + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote("/path/with spaces/and'quotes", baseConfig, deps); + + expect(deps.execSsh).toHaveBeenCalledWith('ssh', expect.any(Array)); + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + // Path should be properly escaped in the command + expect(remoteCommand).toContain("'/path/with spaces/and'\\''quotes'"); + }); + }); + + describe('readFileRemote', () => { + it('returns file contents successfully', async () => { + const deps = createMockDeps({ + stdout: '# README\n\nThis is my project.\n', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/project/README.md', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe('# README\n\nThis is my project.\n'); + }); + + it('handles file not found error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /missing.txt: No such file or directory', + exitCode: 1, + }); + + const result = await readFileRemote('/missing.txt', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('File not found'); + }); + + it('handles permission denied error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /etc/shadow: Permission denied', + exitCode: 1, + }); + + const result = await readFileRemote('/etc/shadow', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles reading directory error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /etc/: Is a directory', + exitCode: 1, + }); + + const result = await readFileRemote('/etc/', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('is a directory'); + }); + + it('handles empty file', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/empty.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(''); + }); + + it('preserves binary-safe content (within UTF-8)', async () => { + const deps = createMockDeps({ + stdout: 'Line 1\nLine 2\r\nLine 3\tTabbed', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe('Line 1\nLine 2\r\nLine 3\tTabbed'); + }); + }); + + describe('statRemote', () => { + it('parses GNU stat output for regular file', async () => { + const deps = createMockDeps({ + stdout: '1234\nregular file\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/package.json', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + size: 1234, + isDirectory: false, + mtime: 1703836800000, // Converted to milliseconds + }); + }); + + it('parses GNU stat output for directory', async () => { + const deps = createMockDeps({ + stdout: '4096\ndirectory\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/src', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data?.isDirectory).toBe(true); + }); + + it('parses BSD stat output format', async () => { + // BSD stat -f '%z\n%HT\n%m' format + const deps = createMockDeps({ + stdout: '5678\nRegular File\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + size: 5678, + isDirectory: false, + mtime: 1703836800000, + }); + }); + + it('handles file not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "stat: cannot stat '/missing': No such file or directory", + exitCode: 1, + }); + + const result = await statRemote('/missing', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "stat: cannot stat '/protected': Permission denied", + exitCode: 1, + }); + + const result = await statRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles invalid output format', async () => { + const deps = createMockDeps({ + stdout: 'invalid\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid stat output'); + }); + + it('handles non-numeric values in output', async () => { + const deps = createMockDeps({ + stdout: 'notanumber\nregular file\nalsonotanumber\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to parse stat output'); + }); + }); + + describe('directorySizeRemote', () => { + it('parses du -sb output (GNU)', async () => { + const deps = createMockDeps({ + stdout: '123456789\t/project\n', + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(123456789); + }); + + it('parses awk-processed du -sk output (BSD fallback)', async () => { + const deps = createMockDeps({ + stdout: '1234567890\n', // Awk output (size * 1024) + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(1234567890); + }); + + it('handles directory not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "du: cannot access '/missing': No such file or directory", + exitCode: 1, + }); + + const result = await directorySizeRemote('/missing', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "du: cannot read directory '/protected': Permission denied", + exitCode: 1, + }); + + const result = await directorySizeRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles invalid output format', async () => { + const deps = createMockDeps({ + stdout: 'invalid output\n', + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/dir', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to parse du output'); + }); + }); + + describe('writeFileRemote', () => { + it('writes content successfully using base64 encoding', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await writeFileRemote('/output.txt', 'Hello, World!', baseConfig, deps); + + expect(result.success).toBe(true); + // Verify the SSH command includes base64-encoded content + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('base64 -d'); + }); + + it('handles content with special characters', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const content = "Line 1\nLine 2 with 'quotes' and $variables"; + const result = await writeFileRemote('/output.txt', content, baseConfig, deps); + + expect(result.success).toBe(true); + // Verify base64 encoding is used (safe for special chars) + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain(Buffer.from(content, 'utf-8').toString('base64')); + }); + + it('handles permission denied on write', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '/etc/test.txt: Permission denied', + exitCode: 1, + }); + + const result = await writeFileRemote('/etc/test.txt', 'test', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles parent directory not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '/nonexistent/file.txt: No such file or directory', + exitCode: 1, + }); + + const result = await writeFileRemote('/nonexistent/file.txt', 'test', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Parent directory not found'); + }); + }); + + describe('existsRemote', () => { + it('returns true when path exists', async () => { + const deps = createMockDeps({ + stdout: 'EXISTS\n', + stderr: '', + exitCode: 0, + }); + + const result = await existsRemote('/home/user/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(true); + }); + + it('returns false when path does not exist', async () => { + const deps = createMockDeps({ + stdout: 'NOT_EXISTS\n', + stderr: '', + exitCode: 0, + }); + + const result = await existsRemote('/nonexistent', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(false); + }); + + it('handles SSH error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Connection refused', + exitCode: 1, + }); + + const result = await existsRemote('/path', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('mkdirRemote', () => { + it('creates directory successfully', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await mkdirRemote('/home/user/newdir', baseConfig, true, deps); + + expect(result.success).toBe(true); + }); + + it('uses -p flag for recursive creation', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + await mkdirRemote('/home/user/a/b/c', baseConfig, true, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('mkdir -p'); + }); + + it('omits -p flag when recursive is false', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + await mkdirRemote('/home/user/newdir', baseConfig, false, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('mkdir '); + expect(remoteCommand).not.toContain('-p'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "mkdir: cannot create directory '/etc/test': Permission denied", + exitCode: 1, + }); + + const result = await mkdirRemote('/etc/test', baseConfig, true, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles directory already exists', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "mkdir: cannot create directory '/home': File exists", + exitCode: 1, + }); + + const result = await mkdirRemote('/home', baseConfig, false, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + }); + + describe('SSH context integration', () => { + it('passes correct SSH remote config to buildSshArgs', async () => { + const customConfig: SshRemoteConfig = { + ...baseConfig, + host: 'custom.host.com', + port: 2222, + username: 'customuser', + }; + + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote('/path', customConfig, deps); + + expect(deps.buildSshArgs).toHaveBeenCalledWith(customConfig); + }); + + it('handles useSshConfig mode correctly', async () => { + const sshConfigMode: SshRemoteConfig = { + ...baseConfig, + useSshConfig: true, + privateKeyPath: '', + username: '', + }; + + const deps = createMockDeps({ + stdout: 'EXISTS\n', + stderr: '', + exitCode: 0, + }); + + await existsRemote('/test', sshConfigMode, deps); + + expect(deps.buildSshArgs).toHaveBeenCalledWith(sshConfigMode); + }); + }); + + describe('error handling edge cases', () => { + it('handles network timeout', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Connection timed out', + exitCode: 255, + }); + + const result = await readFileRemote('/file.txt', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('handles SSH authentication failure', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Permission denied (publickey)', + exitCode: 255, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + }); + + it('handles empty response with non-zero exit code', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 1, + }); + + const result = await readFileRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/src/main/utils/remote-fs.ts b/src/main/utils/remote-fs.ts new file mode 100644 index 00000000..3460d871 --- /dev/null +++ b/src/main/utils/remote-fs.ts @@ -0,0 +1,483 @@ +/** + * Remote File System utilities for SSH remote execution. + * + * Provides functions to perform file system operations on remote hosts via SSH. + * These utilities enable File Explorer, Auto Run, and other features to work + * when a session is running on a remote host. + * + * All functions accept a SshRemoteConfig and execute the corresponding + * Unix commands (ls, cat, stat, du) via SSH, parsing their output. + */ + +import { SshRemoteConfig } from '../../shared/types'; +import { execFileNoThrow, ExecResult } from './execFile'; +import { shellEscape } from './shell-escape'; +import { sshRemoteManager } from '../ssh-remote-manager'; + +/** + * File or directory entry returned from readDir operations. + */ +export interface RemoteDirEntry { + /** File or directory name */ + name: string; + /** Whether this entry is a directory */ + isDirectory: boolean; + /** Whether this entry is a symbolic link */ + isSymlink: boolean; +} + +/** + * File stat information returned from stat operations. + */ +export interface RemoteStatResult { + /** File size in bytes */ + size: number; + /** Whether this is a directory */ + isDirectory: boolean; + /** Modification time as Unix timestamp (milliseconds) */ + mtime: number; +} + +/** + * Result wrapper for remote fs operations. + * Includes success/failure status and optional error message. + */ +export interface RemoteFsResult { + /** Whether the operation succeeded */ + success: boolean; + /** The result data (if success is true) */ + data?: T; + /** Error message (if success is false) */ + error?: string; +} + +/** + * Dependencies that can be injected for testing. + */ +export interface RemoteFsDeps { + /** Function to execute SSH commands */ + execSsh: (command: string, args: string[]) => Promise; + /** Function to build SSH args from config */ + buildSshArgs: (config: SshRemoteConfig) => string[]; +} + +/** + * Default dependencies using real implementations. + */ +const defaultDeps: RemoteFsDeps = { + execSsh: (command: string, args: string[]): Promise => { + return execFileNoThrow(command, args); + }, + buildSshArgs: (config: SshRemoteConfig): string[] => { + return sshRemoteManager.buildSshArgs(config); + }, +}; + +/** + * Execute a command on a remote host via SSH. + * + * @param config SSH remote configuration + * @param remoteCommand The shell command to execute on the remote + * @param deps Optional dependencies for testing + * @returns ExecResult with stdout, stderr, and exitCode + */ +async function execRemoteCommand( + config: SshRemoteConfig, + remoteCommand: string, + deps: RemoteFsDeps = defaultDeps +): Promise { + const sshArgs = deps.buildSshArgs(config); + sshArgs.push(remoteCommand); + return deps.execSsh('ssh', sshArgs); +} + +/** + * Read directory contents from a remote host via SSH. + * + * Executes `ls -la` on the remote and parses the output to extract + * file names, types (directory, file, symlink), and other metadata. + * + * @param dirPath Path to the directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Array of directory entries + * + * @example + * const entries = await readDirRemote('/home/user/project', sshConfig); + * // => [{ name: 'src', isDirectory: true, isSymlink: false }, ...] + */ +export async function readDirRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + // Use ls with specific options: + // -1: One entry per line + // -A: Show hidden files except . and .. + // -F: Append indicator (/ for dirs, @ for symlinks, * for executables) + // --color=never: Disable color codes in output + // We avoid -l because parsing long format is complex and locale-dependent + const escapedPath = shellEscape(dirPath); + const remoteCommand = `ls -1AF --color=never ${escapedPath} 2>/dev/null || echo "__LS_ERROR__"`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0 && !result.stdout.includes('__LS_ERROR__')) { + return { + success: false, + error: result.stderr || `ls failed with exit code ${result.exitCode}`, + }; + } + + // Check for our error marker + if (result.stdout.trim() === '__LS_ERROR__') { + return { + success: false, + error: `Directory not found or not accessible: ${dirPath}`, + }; + } + + const entries: RemoteDirEntry[] = []; + const lines = result.stdout.trim().split('\n').filter(Boolean); + + for (const line of lines) { + if (!line || line === '__LS_ERROR__') continue; + + let name = line; + let isDirectory = false; + let isSymlink = false; + + // Parse the indicator suffix from -F flag + if (name.endsWith('/')) { + name = name.slice(0, -1); + isDirectory = true; + } else if (name.endsWith('@')) { + name = name.slice(0, -1); + isSymlink = true; + } else if (name.endsWith('*')) { + // Executable file - remove the indicator + name = name.slice(0, -1); + } else if (name.endsWith('|')) { + // Named pipe - remove the indicator + name = name.slice(0, -1); + } else if (name.endsWith('=')) { + // Socket - remove the indicator + name = name.slice(0, -1); + } + + // Skip empty names (shouldn't happen, but be safe) + if (!name) continue; + + entries.push({ name, isDirectory, isSymlink }); + } + + return { + success: true, + data: entries, + }; +} + +/** + * Read file contents from a remote host via SSH. + * + * Executes `cat` on the remote to read the file contents. + * For binary files or very large files, consider using different approaches. + * + * @param filePath Path to the file on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns File contents as a string + * + * @example + * const content = await readFileRemote('/home/user/project/README.md', sshConfig); + * // => '# My Project\n...' + */ +export async function readFileRemote( + filePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + // Use cat with explicit error handling + const remoteCommand = `cat ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to read file: ${filePath}`; + return { + success: false, + error: error.includes('No such file') + ? `File not found: ${filePath}` + : error.includes('Is a directory') + ? `Path is a directory: ${filePath}` + : error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error, + }; + } + + return { + success: true, + data: result.stdout, + }; +} + +/** + * Get file/directory stat information from a remote host via SSH. + * + * Executes `stat` on the remote with a specific format string to get + * size, type, and modification time. + * + * @param filePath Path to the file or directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Stat information (size, isDirectory, mtime) + * + * @example + * const stats = await statRemote('/home/user/project/package.json', sshConfig); + * // => { size: 1234, isDirectory: false, mtime: 1703836800000 } + */ +export async function statRemote( + filePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + // Use stat with format string: + // %s = size in bytes + // %F = file type (regular file, directory, symbolic link, etc.) + // %Y = modification time as Unix timestamp (seconds) + // Note: GNU stat vs BSD stat have different format specifiers + // We try GNU format first (Linux), then BSD format (macOS) + const remoteCommand = `stat --printf='%s\\n%F\\n%Y' ${escapedPath} 2>/dev/null || stat -f '%z\\n%HT\\n%m' ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to stat: ${filePath}`; + return { + success: false, + error: error.includes('No such file') + ? `Path not found: ${filePath}` + : error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error, + }; + } + + const lines = result.stdout.trim().split('\n'); + if (lines.length < 3) { + return { + success: false, + error: `Invalid stat output for: ${filePath}`, + }; + } + + const size = parseInt(lines[0], 10); + const fileType = lines[1].toLowerCase(); + const mtimeSeconds = parseInt(lines[2], 10); + + if (isNaN(size) || isNaN(mtimeSeconds)) { + return { + success: false, + error: `Failed to parse stat output for: ${filePath}`, + }; + } + + // Determine if it's a directory from the file type string + // GNU stat returns: "regular file", "directory", "symbolic link" + // BSD stat returns: "Regular File", "Directory", "Symbolic Link" + const isDirectory = fileType.includes('directory'); + + return { + success: true, + data: { + size, + isDirectory, + mtime: mtimeSeconds * 1000, // Convert to milliseconds + }, + }; +} + +/** + * Get total size of a directory from a remote host via SSH. + * + * Executes `du -sb` on the remote to calculate the total size + * of all files in the directory. + * + * @param dirPath Path to the directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Total size in bytes + * + * @example + * const size = await directorySizeRemote('/home/user/project', sshConfig); + * // => 1234567890 + */ +export async function directorySizeRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + // Use du with: + // -s: summarize (total only) + // -b: apparent size in bytes (GNU) + // If -b not available (BSD), use -k and multiply by 1024 + const remoteCommand = `du -sb ${escapedPath} 2>/dev/null || du -sk ${escapedPath} 2>/dev/null | awk '{print $1 * 1024}'`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to get directory size: ${dirPath}`; + return { + success: false, + error: error.includes('No such file') + ? `Directory not found: ${dirPath}` + : error.includes('Permission denied') + ? `Permission denied: ${dirPath}` + : error, + }; + } + + // Parse the size from the output (first field) + const output = result.stdout.trim(); + const match = output.match(/^(\d+)/); + + if (!match) { + return { + success: false, + error: `Failed to parse du output for: ${dirPath}`, + }; + } + + const size = parseInt(match[1], 10); + + if (isNaN(size)) { + return { + success: false, + error: `Invalid size value for: ${dirPath}`, + }; + } + + return { + success: true, + data: size, + }; +} + +/** + * Write file contents to a remote host via SSH. + * + * Uses cat with a heredoc to safely write content to a file on the remote. + * This is safe for text content but not recommended for binary files. + * + * @param filePath Path to the file on the remote host + * @param content Content to write + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Success/failure result + * + * @example + * const result = await writeFileRemote('/home/user/project/output.txt', 'Hello!', sshConfig); + * // => { success: true } + */ +export async function writeFileRemote( + filePath: string, + content: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + + // Use base64 encoding to safely transfer the content + // This avoids issues with special characters, quotes, and newlines + const base64Content = Buffer.from(content, 'utf-8').toString('base64'); + + // Decode base64 on remote and write to file + const remoteCommand = `echo '${base64Content}' | base64 -d > ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to write file: ${filePath}`; + return { + success: false, + error: error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error.includes('No such file') + ? `Parent directory not found: ${filePath}` + : error, + }; + } + + return { success: true }; +} + +/** + * Check if a path exists on a remote host via SSH. + * + * @param remotePath Path to check + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Whether the path exists + */ +export async function existsRemote( + remotePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(remotePath); + const remoteCommand = `test -e ${escapedPath} && echo "EXISTS" || echo "NOT_EXISTS"`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + return { + success: false, + error: result.stderr || 'Failed to check path existence', + }; + } + + return { + success: true, + data: result.stdout.trim() === 'EXISTS', + }; +} + +/** + * Create a directory on a remote host via SSH. + * + * @param dirPath Directory path to create + * @param sshRemote SSH remote configuration + * @param recursive Whether to create parent directories (mkdir -p) + * @param deps Optional dependencies for testing + * @returns Success/failure result + */ +export async function mkdirRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + recursive: boolean = true, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + const mkdirFlag = recursive ? '-p' : ''; + const remoteCommand = `mkdir ${mkdirFlag} ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to create directory: ${dirPath}`; + return { + success: false, + error: error.includes('Permission denied') + ? `Permission denied: ${dirPath}` + : error.includes('File exists') + ? `Directory already exists: ${dirPath}` + : error, + }; + } + + return { success: true }; +}