mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Add remote-fs module for SSH file system operations
Creates src/main/utils/remote-fs.ts with SSH wrappers for: - readDirRemote: ls -1AF command parsing - readFileRemote: cat command for file contents - statRemote: stat command with GNU/BSD format support - directorySizeRemote: du command for directory sizes - writeFileRemote: base64-encoded file writing - existsRemote: test -e path existence check - mkdirRemote: directory creation All functions use shell escaping for security and return RemoteFsResult<T> with success/failure status and error messages. Includes 43 unit tests covering output parsing, error handling, and SSH context integration.
This commit is contained in:
662
src/__tests__/main/utils/remote-fs.test.ts
Normal file
662
src/__tests__/main/utils/remote-fs.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
483
src/main/utils/remote-fs.ts
Normal file
483
src/main/utils/remote-fs.ts
Normal file
@@ -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<T> {
|
||||
/** 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<ExecResult>;
|
||||
/** 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<ExecResult> => {
|
||||
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<ExecResult> {
|
||||
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<RemoteFsResult<RemoteDirEntry[]>> {
|
||||
// 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<RemoteFsResult<string>> {
|
||||
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<RemoteFsResult<RemoteStatResult>> {
|
||||
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<RemoteFsResult<number>> {
|
||||
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<RemoteFsResult<void>> {
|
||||
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<RemoteFsResult<boolean>> {
|
||||
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<RemoteFsResult<void>> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user