@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.2
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/README.md +166 -133
- package/build/config/config-loader.js +60 -0
- package/build/config/server.js +13 -13
- package/build/config/ssh-config-loader.js +45 -2
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +274 -164
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/list-servers.js +6 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +20 -11
- package/build/tests/command-validation.test.js +0 -876
- package/build/tests/config.test.js +0 -617
- package/build/tests/integration.test.js +0 -348
- package/build/tests/output-collector.test.js +0 -70
- package/build/tests/output-log-writer.test.js +0 -205
- package/build/tests/recent-fixes.test.js +0 -229
- package/build/tests/tool-error.test.js +0 -26
- package/build/tests/tools.test.js +0 -486
|
@@ -1,876 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Command Validation Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests the whitelist/blacklist pattern matching logic
|
|
5
|
-
* without requiring actual SSH connections.
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, describe, it } from "node:test";
|
|
8
|
-
import assert from "node:assert";
|
|
9
|
-
import os from "node:os";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import fsForTest from "node:fs";
|
|
12
|
-
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
|
13
|
-
import { ToolError } from "../utils/tool-error.js";
|
|
14
|
-
/**
|
|
15
|
-
* Simulate the whitelist/blacklist checking logic
|
|
16
|
-
* (extracted from ssh-connection-manager.ts for testing)
|
|
17
|
-
*/
|
|
18
|
-
function isCommandAllowed(command, whitelist, blacklist = []) {
|
|
19
|
-
// Check whitelist - command must match one pattern to be allowed
|
|
20
|
-
let matchesWhitelist = false;
|
|
21
|
-
let matchedWhitelistPattern;
|
|
22
|
-
for (const pattern of whitelist) {
|
|
23
|
-
try {
|
|
24
|
-
const regex = new RegExp(pattern);
|
|
25
|
-
if (regex.test(command)) {
|
|
26
|
-
matchesWhitelist = true;
|
|
27
|
-
matchedWhitelistPattern = pattern;
|
|
28
|
-
break;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
// Invalid regex pattern, skip
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (!matchesWhitelist) {
|
|
36
|
-
return {
|
|
37
|
-
allowed: false,
|
|
38
|
-
reason: `Command does not match any whitelist pattern: "${command}"`
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
// Check blacklist - command must NOT match any pattern
|
|
42
|
-
for (const pattern of blacklist) {
|
|
43
|
-
try {
|
|
44
|
-
const regex = new RegExp(pattern);
|
|
45
|
-
if (regex.test(command)) {
|
|
46
|
-
return {
|
|
47
|
-
allowed: false,
|
|
48
|
-
matchedPattern: pattern,
|
|
49
|
-
reason: `Command matches blacklist pattern: ${pattern}`
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// Invalid regex pattern, skip
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
allowed: true,
|
|
59
|
-
matchedPattern: matchedWhitelistPattern
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
describe("Whitelist Patterns", () => {
|
|
63
|
-
const basicWhitelist = [
|
|
64
|
-
"^ls( .*)?$",
|
|
65
|
-
"^cat .*$",
|
|
66
|
-
"^pwd$",
|
|
67
|
-
"^docker ps.*$",
|
|
68
|
-
"^docker logs.*$",
|
|
69
|
-
"^git .*$",
|
|
70
|
-
];
|
|
71
|
-
it("should allow 'ls' command", () => {
|
|
72
|
-
const result = isCommandAllowed("ls", basicWhitelist);
|
|
73
|
-
assert.strictEqual(result.allowed, true);
|
|
74
|
-
});
|
|
75
|
-
it("should allow 'ls -la' command", () => {
|
|
76
|
-
const result = isCommandAllowed("ls -la", basicWhitelist);
|
|
77
|
-
assert.strictEqual(result.allowed, true);
|
|
78
|
-
});
|
|
79
|
-
it("should allow 'ls /var/log' command", () => {
|
|
80
|
-
const result = isCommandAllowed("ls /var/log", basicWhitelist);
|
|
81
|
-
assert.strictEqual(result.allowed, true);
|
|
82
|
-
});
|
|
83
|
-
it("should allow 'cat /etc/hosts' command", () => {
|
|
84
|
-
const result = isCommandAllowed("cat /etc/hosts", basicWhitelist);
|
|
85
|
-
assert.strictEqual(result.allowed, true);
|
|
86
|
-
});
|
|
87
|
-
it("should allow 'pwd' command", () => {
|
|
88
|
-
const result = isCommandAllowed("pwd", basicWhitelist);
|
|
89
|
-
assert.strictEqual(result.allowed, true);
|
|
90
|
-
});
|
|
91
|
-
it("should allow 'docker ps' command", () => {
|
|
92
|
-
const result = isCommandAllowed("docker ps", basicWhitelist);
|
|
93
|
-
assert.strictEqual(result.allowed, true);
|
|
94
|
-
});
|
|
95
|
-
it("should allow 'docker ps -a' command", () => {
|
|
96
|
-
const result = isCommandAllowed("docker ps -a", basicWhitelist);
|
|
97
|
-
assert.strictEqual(result.allowed, true);
|
|
98
|
-
});
|
|
99
|
-
it("should allow 'docker logs mycontainer' command", () => {
|
|
100
|
-
const result = isCommandAllowed("docker logs mycontainer", basicWhitelist);
|
|
101
|
-
assert.strictEqual(result.allowed, true);
|
|
102
|
-
});
|
|
103
|
-
it("should allow 'docker logs -f --tail 100 myapp' command", () => {
|
|
104
|
-
const result = isCommandAllowed("docker logs -f --tail 100 myapp", basicWhitelist);
|
|
105
|
-
assert.strictEqual(result.allowed, true);
|
|
106
|
-
});
|
|
107
|
-
it("should allow 'git status' command", () => {
|
|
108
|
-
const result = isCommandAllowed("git status", basicWhitelist);
|
|
109
|
-
assert.strictEqual(result.allowed, true);
|
|
110
|
-
});
|
|
111
|
-
it("should allow 'git pull' command", () => {
|
|
112
|
-
const result = isCommandAllowed("git pull", basicWhitelist);
|
|
113
|
-
assert.strictEqual(result.allowed, true);
|
|
114
|
-
});
|
|
115
|
-
it("should BLOCK 'rm -rf /' command", () => {
|
|
116
|
-
const result = isCommandAllowed("rm -rf /", basicWhitelist);
|
|
117
|
-
assert.strictEqual(result.allowed, false);
|
|
118
|
-
});
|
|
119
|
-
it("should BLOCK 'shutdown' command", () => {
|
|
120
|
-
const result = isCommandAllowed("shutdown", basicWhitelist);
|
|
121
|
-
assert.strictEqual(result.allowed, false);
|
|
122
|
-
});
|
|
123
|
-
it("should BLOCK 'reboot' command", () => {
|
|
124
|
-
const result = isCommandAllowed("reboot", basicWhitelist);
|
|
125
|
-
assert.strictEqual(result.allowed, false);
|
|
126
|
-
});
|
|
127
|
-
it("should BLOCK unknown commands", () => {
|
|
128
|
-
const result = isCommandAllowed("some-random-command --with-args", basicWhitelist);
|
|
129
|
-
assert.strictEqual(result.allowed, false);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
describe("Blacklist Patterns", () => {
|
|
133
|
-
const permissiveWhitelist = ["^.*$"]; // Allow everything
|
|
134
|
-
const dangerousBlacklist = [
|
|
135
|
-
"^rm .*$",
|
|
136
|
-
"^rmdir .*$",
|
|
137
|
-
"^shutdown.*$",
|
|
138
|
-
"^reboot.*$",
|
|
139
|
-
"^dd .*$",
|
|
140
|
-
"^mkfs.*$",
|
|
141
|
-
];
|
|
142
|
-
it("should allow 'ls' with permissive whitelist", () => {
|
|
143
|
-
const result = isCommandAllowed("ls", permissiveWhitelist, dangerousBlacklist);
|
|
144
|
-
assert.strictEqual(result.allowed, true);
|
|
145
|
-
});
|
|
146
|
-
it("should BLOCK 'rm file' even with permissive whitelist", () => {
|
|
147
|
-
const result = isCommandAllowed("rm file", permissiveWhitelist, dangerousBlacklist);
|
|
148
|
-
assert.strictEqual(result.allowed, false);
|
|
149
|
-
});
|
|
150
|
-
it("should BLOCK 'rm -rf /' with blacklist", () => {
|
|
151
|
-
const result = isCommandAllowed("rm -rf /", permissiveWhitelist, dangerousBlacklist);
|
|
152
|
-
assert.strictEqual(result.allowed, false);
|
|
153
|
-
});
|
|
154
|
-
it("should BLOCK 'shutdown -h now' with blacklist", () => {
|
|
155
|
-
const result = isCommandAllowed("shutdown -h now", permissiveWhitelist, dangerousBlacklist);
|
|
156
|
-
assert.strictEqual(result.allowed, false);
|
|
157
|
-
});
|
|
158
|
-
it("should BLOCK 'reboot' with blacklist", () => {
|
|
159
|
-
const result = isCommandAllowed("reboot", permissiveWhitelist, dangerousBlacklist);
|
|
160
|
-
assert.strictEqual(result.allowed, false);
|
|
161
|
-
});
|
|
162
|
-
it("should BLOCK 'dd if=/dev/zero of=/dev/sda' with blacklist", () => {
|
|
163
|
-
const result = isCommandAllowed("dd if=/dev/zero of=/dev/sda", permissiveWhitelist, dangerousBlacklist);
|
|
164
|
-
assert.strictEqual(result.allowed, false);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
describe("Combined Whitelist and Blacklist", () => {
|
|
168
|
-
// Allow docker commands but block dangerous ones
|
|
169
|
-
const whitelist = [
|
|
170
|
-
"^docker .*$",
|
|
171
|
-
"^git .*$",
|
|
172
|
-
];
|
|
173
|
-
const blacklist = [
|
|
174
|
-
"^docker rm .*$",
|
|
175
|
-
"^docker rmi .*$",
|
|
176
|
-
"^docker system prune.*$",
|
|
177
|
-
"^git push --force.*$",
|
|
178
|
-
];
|
|
179
|
-
it("should allow 'docker ps'", () => {
|
|
180
|
-
const result = isCommandAllowed("docker ps", whitelist, blacklist);
|
|
181
|
-
assert.strictEqual(result.allowed, true);
|
|
182
|
-
});
|
|
183
|
-
it("should allow 'docker logs app'", () => {
|
|
184
|
-
const result = isCommandAllowed("docker logs app", whitelist, blacklist);
|
|
185
|
-
assert.strictEqual(result.allowed, true);
|
|
186
|
-
});
|
|
187
|
-
it("should BLOCK 'docker rm container'", () => {
|
|
188
|
-
const result = isCommandAllowed("docker rm container", whitelist, blacklist);
|
|
189
|
-
assert.strictEqual(result.allowed, false);
|
|
190
|
-
});
|
|
191
|
-
it("should BLOCK 'docker rmi image'", () => {
|
|
192
|
-
const result = isCommandAllowed("docker rmi image", whitelist, blacklist);
|
|
193
|
-
assert.strictEqual(result.allowed, false);
|
|
194
|
-
});
|
|
195
|
-
it("should BLOCK 'docker system prune -af'", () => {
|
|
196
|
-
const result = isCommandAllowed("docker system prune -af", whitelist, blacklist);
|
|
197
|
-
assert.strictEqual(result.allowed, false);
|
|
198
|
-
});
|
|
199
|
-
it("should allow 'git status'", () => {
|
|
200
|
-
const result = isCommandAllowed("git status", whitelist, blacklist);
|
|
201
|
-
assert.strictEqual(result.allowed, true);
|
|
202
|
-
});
|
|
203
|
-
it("should allow 'git push origin main'", () => {
|
|
204
|
-
const result = isCommandAllowed("git push origin main", whitelist, blacklist);
|
|
205
|
-
assert.strictEqual(result.allowed, true);
|
|
206
|
-
});
|
|
207
|
-
it("should BLOCK 'git push --force origin main'", () => {
|
|
208
|
-
const result = isCommandAllowed("git push --force origin main", whitelist, blacklist);
|
|
209
|
-
assert.strictEqual(result.allowed, false);
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
describe("Regex Edge Cases", () => {
|
|
213
|
-
const whitelist = [
|
|
214
|
-
"^ls( .*)?$", // ls with optional args
|
|
215
|
-
"^cat .+$", // cat requires an argument
|
|
216
|
-
"^echo .*$", // echo with any args
|
|
217
|
-
];
|
|
218
|
-
it("should match 'ls' without args", () => {
|
|
219
|
-
const result = isCommandAllowed("ls", whitelist);
|
|
220
|
-
assert.strictEqual(result.allowed, true);
|
|
221
|
-
});
|
|
222
|
-
it("should match 'ls -la /var'", () => {
|
|
223
|
-
const result = isCommandAllowed("ls -la /var", whitelist);
|
|
224
|
-
assert.strictEqual(result.allowed, true);
|
|
225
|
-
});
|
|
226
|
-
it("should BLOCK 'cat' without args (requires .+)", () => {
|
|
227
|
-
const result = isCommandAllowed("cat", whitelist);
|
|
228
|
-
assert.strictEqual(result.allowed, false);
|
|
229
|
-
});
|
|
230
|
-
it("should match 'cat file.txt'", () => {
|
|
231
|
-
const result = isCommandAllowed("cat file.txt", whitelist);
|
|
232
|
-
assert.strictEqual(result.allowed, true);
|
|
233
|
-
});
|
|
234
|
-
it("should match 'echo' (echo .* matches empty)", () => {
|
|
235
|
-
const result = isCommandAllowed("echo ", whitelist);
|
|
236
|
-
assert.strictEqual(result.allowed, true);
|
|
237
|
-
});
|
|
238
|
-
it("should match 'echo hello world'", () => {
|
|
239
|
-
const result = isCommandAllowed("echo hello world", whitelist);
|
|
240
|
-
assert.strictEqual(result.allowed, true);
|
|
241
|
-
});
|
|
242
|
-
it("should BLOCK command with leading space", () => {
|
|
243
|
-
const result = isCommandAllowed(" ls", whitelist);
|
|
244
|
-
assert.strictEqual(result.allowed, false); // ^ anchor requires start
|
|
245
|
-
});
|
|
246
|
-
it("should BLOCK command with trailing content beyond pattern", () => {
|
|
247
|
-
// Pattern is ^ls( .*)?$ - the $ requires end
|
|
248
|
-
const result = isCommandAllowed("lsblk", whitelist);
|
|
249
|
-
assert.strictEqual(result.allowed, false);
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
describe("Special Characters in Commands", () => {
|
|
253
|
-
const whitelist = [
|
|
254
|
-
"^echo .*$",
|
|
255
|
-
"^cat .*$",
|
|
256
|
-
];
|
|
257
|
-
it("should allow echo with quotes", () => {
|
|
258
|
-
const result = isCommandAllowed('echo "hello world"', whitelist);
|
|
259
|
-
assert.strictEqual(result.allowed, true);
|
|
260
|
-
});
|
|
261
|
-
it("should allow echo with single quotes", () => {
|
|
262
|
-
const result = isCommandAllowed("echo 'hello world'", whitelist);
|
|
263
|
-
assert.strictEqual(result.allowed, true);
|
|
264
|
-
});
|
|
265
|
-
it("should allow cat with path containing spaces (quoted)", () => {
|
|
266
|
-
const result = isCommandAllowed('cat "/path/with spaces/file.txt"', whitelist);
|
|
267
|
-
assert.strictEqual(result.allowed, true);
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
/**
|
|
271
|
-
* Test the destructive pattern matching logic
|
|
272
|
-
* (extracted from ssh-connection-manager.ts for testing)
|
|
273
|
-
*/
|
|
274
|
-
function getDestructiveMatch(command) {
|
|
275
|
-
const destructivePatterns = [
|
|
276
|
-
{ regex: /\brm\b/, reason: "rm in command chain" },
|
|
277
|
-
{ regex: /\brmdir\b/, reason: "rmdir in command chain" },
|
|
278
|
-
{ regex: /\bunlink\b/, reason: "unlink in command chain" },
|
|
279
|
-
{ regex: /\bshred\b/, reason: "shred in command chain" },
|
|
280
|
-
{ regex: /\btruncate\b/, reason: "truncate in command chain" },
|
|
281
|
-
{ regex: /-delete\b/, reason: "find -delete detected" },
|
|
282
|
-
{ regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
|
|
283
|
-
{ regex: /\bdd\b.*\bof=/, reason: "dd with of= (can overwrite files)" },
|
|
284
|
-
{ regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
|
|
285
|
-
{ regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
|
|
286
|
-
// Allow stderr redirection to /dev/null (2>/dev/null, 2>&1>/dev/null, etc.)
|
|
287
|
-
// Block dangerous writes like: echo x > /etc/passwd, cat > /bin/bash
|
|
288
|
-
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
289
|
-
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
290
|
-
];
|
|
291
|
-
for (const { regex, reason } of destructivePatterns) {
|
|
292
|
-
if (regex.test(command)) {
|
|
293
|
-
return reason;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
describe("Destructive Pattern Detection", () => {
|
|
299
|
-
it("should ALLOW 'find ... 2>/dev/null | head -5' (stderr to /dev/null is safe)", () => {
|
|
300
|
-
const cmd = 'find /var/mobile/Library/Logs -name "*log*" 2>/dev/null | head -5';
|
|
301
|
-
const result = getDestructiveMatch(cmd);
|
|
302
|
-
assert.strictEqual(result, null, `Should allow stderr to /dev/null but got: ${result}`);
|
|
303
|
-
});
|
|
304
|
-
it("should ALLOW 'apt search vnc 2>/dev/null || true'", () => {
|
|
305
|
-
const cmd = "dpkg -l | grep -i vnc; apt search vnc 2>/dev/null || true";
|
|
306
|
-
const result = getDestructiveMatch(cmd);
|
|
307
|
-
assert.strictEqual(result, null, `Should allow stderr to /dev/null but got: ${result}`);
|
|
308
|
-
});
|
|
309
|
-
it("should ALLOW 'cat file 2>/dev/null || echo fallback'", () => {
|
|
310
|
-
const cmd = 'cat /var/mobile/Library/Preferences/file.plist 2>/dev/null || echo "not found"';
|
|
311
|
-
const result = getDestructiveMatch(cmd);
|
|
312
|
-
assert.strictEqual(result, null, `Should allow stderr to /dev/null but got: ${result}`);
|
|
313
|
-
});
|
|
314
|
-
it("should ALLOW '2>&1 > /dev/null' pattern", () => {
|
|
315
|
-
const cmd = "some_command 2>&1 >/dev/null";
|
|
316
|
-
const result = getDestructiveMatch(cmd);
|
|
317
|
-
assert.strictEqual(result, null, `Should allow redirect to /dev/null but got: ${result}`);
|
|
318
|
-
});
|
|
319
|
-
it("should BLOCK 'echo x > /etc/passwd' (dangerous write)", () => {
|
|
320
|
-
const cmd = 'echo "hacked" > /etc/passwd';
|
|
321
|
-
const result = getDestructiveMatch(cmd);
|
|
322
|
-
assert.strictEqual(result, "output redirection to absolute path", `Should block dangerous redirect`);
|
|
323
|
-
});
|
|
324
|
-
it("should BLOCK 'cat malware > /bin/bash' (dangerous write)", () => {
|
|
325
|
-
const cmd = "cat malware > /bin/bash";
|
|
326
|
-
const result = getDestructiveMatch(cmd);
|
|
327
|
-
assert.strictEqual(result, "output redirection to absolute path", `Should block dangerous redirect`);
|
|
328
|
-
});
|
|
329
|
-
it("should BLOCK '> /tmp/file' (stdout redirect to absolute path)", () => {
|
|
330
|
-
const cmd = "echo test > /tmp/file";
|
|
331
|
-
const result = getDestructiveMatch(cmd);
|
|
332
|
-
assert.strictEqual(result, "output redirection to absolute path", `Should block stdout redirect`);
|
|
333
|
-
});
|
|
334
|
-
it("should BLOCK 'rm -rf /' command", () => {
|
|
335
|
-
const result = getDestructiveMatch("rm -rf /");
|
|
336
|
-
assert.strictEqual(result, "rm in command chain");
|
|
337
|
-
});
|
|
338
|
-
it("should BLOCK 'echo test && rm file' (hidden rm)", () => {
|
|
339
|
-
const result = getDestructiveMatch("echo test && rm file");
|
|
340
|
-
assert.strictEqual(result, "rm in command chain");
|
|
341
|
-
});
|
|
342
|
-
it("should BLOCK 'find -delete' pattern", () => {
|
|
343
|
-
const result = getDestructiveMatch("find /tmp -name '*.log' -delete");
|
|
344
|
-
assert.strictEqual(result, "find -delete detected");
|
|
345
|
-
});
|
|
346
|
-
it("should BLOCK 'find -exec rm' pattern", () => {
|
|
347
|
-
const result = getDestructiveMatch("find /tmp -type f -exec rm {} \\;");
|
|
348
|
-
// Note: rm word boundary pattern matches first, which is also correct
|
|
349
|
-
assert.ok(result !== null, "Should block find -exec rm");
|
|
350
|
-
assert.ok(result.includes("rm"), `Should be blocked due to rm, got: ${result}`);
|
|
351
|
-
});
|
|
352
|
-
it("should BLOCK 'dd of=' pattern", () => {
|
|
353
|
-
const result = getDestructiveMatch("dd if=/dev/zero of=/dev/sda bs=4M");
|
|
354
|
-
assert.strictEqual(result, "dd with of= (can overwrite files)");
|
|
355
|
-
});
|
|
356
|
-
it("should BLOCK '> ~/' (home path redirect)", () => {
|
|
357
|
-
const result = getDestructiveMatch("echo test > ~/file");
|
|
358
|
-
assert.strictEqual(result, "output redirection to home path");
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
describe("SSHConnectionManager regressions", () => {
|
|
362
|
-
const manager = SSHConnectionManager.getInstance();
|
|
363
|
-
const baseConfig = (overrides = {}) => ({
|
|
364
|
-
name: "dev",
|
|
365
|
-
host: "127.0.0.1",
|
|
366
|
-
port: 22,
|
|
367
|
-
username: "root",
|
|
368
|
-
password: "test-password",
|
|
369
|
-
safeDirectory: "/root",
|
|
370
|
-
...overrides,
|
|
371
|
-
});
|
|
372
|
-
afterEach(() => {
|
|
373
|
-
manager.disconnect();
|
|
374
|
-
manager.setConfig({}, undefined);
|
|
375
|
-
});
|
|
376
|
-
it("should require safe rm to still match the whitelist", () => {
|
|
377
|
-
manager.setConfig({
|
|
378
|
-
dev: baseConfig({
|
|
379
|
-
commandWhitelist: ["^pwd$"],
|
|
380
|
-
}),
|
|
381
|
-
}, ["dev"]);
|
|
382
|
-
const result = manager.validateCommand("rm /root/test-file", "dev");
|
|
383
|
-
assert.strictEqual(result.isAllowed, false);
|
|
384
|
-
assert.match(result.reason ?? "", /whitelist/i);
|
|
385
|
-
});
|
|
386
|
-
it("should let blacklist override a safe rm command", () => {
|
|
387
|
-
manager.setConfig({
|
|
388
|
-
dev: baseConfig({
|
|
389
|
-
commandWhitelist: ["^rm .*$"],
|
|
390
|
-
commandBlacklist: ["^rm .*$"],
|
|
391
|
-
}),
|
|
392
|
-
}, ["dev"]);
|
|
393
|
-
const result = manager.validateCommand("rm /root/test-file", "dev");
|
|
394
|
-
assert.strictEqual(result.isAllowed, false);
|
|
395
|
-
assert.match(result.reason ?? "", /blacklist/i);
|
|
396
|
-
});
|
|
397
|
-
it("should allow safe rm only when it passes policy checks", () => {
|
|
398
|
-
manager.setConfig({
|
|
399
|
-
dev: baseConfig({
|
|
400
|
-
commandWhitelist: ["^rm .*$"],
|
|
401
|
-
}),
|
|
402
|
-
}, ["dev"]);
|
|
403
|
-
const result = manager.validateCommand("rm /root/test-file", "dev");
|
|
404
|
-
assert.strictEqual(result.isAllowed, true);
|
|
405
|
-
});
|
|
406
|
-
it("should not retry command timeout errors", async () => {
|
|
407
|
-
manager.setConfig({
|
|
408
|
-
dev: baseConfig({
|
|
409
|
-
commandWhitelist: ["^pwd$"],
|
|
410
|
-
}),
|
|
411
|
-
}, ["dev"]);
|
|
412
|
-
const originalEnsureConnected = manager.ensureConnected;
|
|
413
|
-
const originalRunCommandStream = manager.runCommandStream;
|
|
414
|
-
const originalReconnect = manager.reconnect;
|
|
415
|
-
const originalCreateLogWriter = manager.createLogWriter;
|
|
416
|
-
let executeCalls = 0;
|
|
417
|
-
let reconnectCalls = 0;
|
|
418
|
-
manager.ensureConnected = async () => ({});
|
|
419
|
-
manager.createLogWriter = () => null; // skip disk writes in this test
|
|
420
|
-
manager.runCommandStream = async () => {
|
|
421
|
-
executeCalls += 1;
|
|
422
|
-
throw new ToolError("COMMAND_TIMEOUT", "timed out", false);
|
|
423
|
-
};
|
|
424
|
-
manager.reconnect = async () => {
|
|
425
|
-
reconnectCalls += 1;
|
|
426
|
-
};
|
|
427
|
-
try {
|
|
428
|
-
await assert.rejects(() => manager.executeCommand("pwd", "dev", { timeout: 1, maxRetries: 2 }), (error) => error instanceof ToolError && error.code === "COMMAND_TIMEOUT");
|
|
429
|
-
assert.strictEqual(executeCalls, 1);
|
|
430
|
-
assert.strictEqual(reconnectCalls, 0);
|
|
431
|
-
}
|
|
432
|
-
finally {
|
|
433
|
-
manager.ensureConnected = originalEnsureConnected;
|
|
434
|
-
manager.runCommandStream = originalRunCommandStream;
|
|
435
|
-
manager.reconnect = originalReconnect;
|
|
436
|
-
manager.createLogWriter = originalCreateLogWriter;
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
it("should continue retrying SSH connection failures", async () => {
|
|
440
|
-
manager.setConfig({
|
|
441
|
-
dev: baseConfig({
|
|
442
|
-
commandWhitelist: ["^pwd$"],
|
|
443
|
-
}),
|
|
444
|
-
}, ["dev"]);
|
|
445
|
-
const originalEnsureConnected = manager.ensureConnected;
|
|
446
|
-
const originalRunCommandStream = manager.runCommandStream;
|
|
447
|
-
const originalReconnect = manager.reconnect;
|
|
448
|
-
const originalCreateLogWriter = manager.createLogWriter;
|
|
449
|
-
let executeCalls = 0;
|
|
450
|
-
let reconnectCalls = 0;
|
|
451
|
-
manager.ensureConnected = async () => ({});
|
|
452
|
-
manager.createLogWriter = () => null;
|
|
453
|
-
manager.runCommandStream = async (_cmd, _client, _timeout, sinks) => {
|
|
454
|
-
executeCalls += 1;
|
|
455
|
-
if (executeCalls === 1) {
|
|
456
|
-
throw new ToolError("SSH_CONNECTION_FAILED", "socket closed", true);
|
|
457
|
-
}
|
|
458
|
-
sinks.stdoutCollector?.push("ok");
|
|
459
|
-
return 0;
|
|
460
|
-
};
|
|
461
|
-
manager.reconnect = async () => {
|
|
462
|
-
reconnectCalls += 1;
|
|
463
|
-
};
|
|
464
|
-
try {
|
|
465
|
-
const result = await manager.executeCommand("pwd", "dev", { timeout: 1, maxRetries: 1 });
|
|
466
|
-
assert.strictEqual(result, "ok");
|
|
467
|
-
assert.strictEqual(executeCalls, 2);
|
|
468
|
-
assert.strictEqual(reconnectCalls, 1);
|
|
469
|
-
}
|
|
470
|
-
finally {
|
|
471
|
-
manager.ensureConnected = originalEnsureConnected;
|
|
472
|
-
manager.runCommandStream = originalRunCommandStream;
|
|
473
|
-
manager.reconnect = originalReconnect;
|
|
474
|
-
manager.createLogWriter = originalCreateLogWriter;
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
it("should pre-connect only enabled servers", async () => {
|
|
478
|
-
manager.setConfig({
|
|
479
|
-
dev: baseConfig({ name: "dev" }),
|
|
480
|
-
prod: baseConfig({ name: "prod", host: "127.0.0.2" }),
|
|
481
|
-
}, ["dev"]);
|
|
482
|
-
const originalConnect = manager.connect;
|
|
483
|
-
const connectedNames = [];
|
|
484
|
-
manager.connect = async (name) => {
|
|
485
|
-
connectedNames.push(name ?? manager.defaultName);
|
|
486
|
-
};
|
|
487
|
-
try {
|
|
488
|
-
await manager.connectAll();
|
|
489
|
-
assert.deepStrictEqual(connectedNames, ["dev"]);
|
|
490
|
-
}
|
|
491
|
-
finally {
|
|
492
|
-
manager.connect = originalConnect;
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
it("should throw a structured validation error for blocked commands", async () => {
|
|
496
|
-
manager.setConfig({
|
|
497
|
-
dev: baseConfig({
|
|
498
|
-
commandWhitelist: ["^pwd$"],
|
|
499
|
-
}),
|
|
500
|
-
}, ["dev"]);
|
|
501
|
-
await assert.rejects(() => manager.executeCommand("whoami", "dev", { maxRetries: 0 }), (error) => error instanceof ToolError
|
|
502
|
-
&& error.code === "COMMAND_VALIDATION_FAILED"
|
|
503
|
-
&& /whitelist/i.test(error.message));
|
|
504
|
-
});
|
|
505
|
-
it("should throw a structured local-path error before upload starts", async () => {
|
|
506
|
-
manager.setConfig({
|
|
507
|
-
dev: baseConfig(),
|
|
508
|
-
}, ["dev"]);
|
|
509
|
-
await assert.rejects(() => manager.upload("..\\outside-file.txt", "/tmp/outside-file.txt", "dev"), (error) => error instanceof ToolError && error.code === "LOCAL_PATH_NOT_ALLOWED");
|
|
510
|
-
});
|
|
511
|
-
it("should throw a structured authentication error when no auth method is configured", async () => {
|
|
512
|
-
manager.setConfig({
|
|
513
|
-
dev: {
|
|
514
|
-
name: "dev",
|
|
515
|
-
host: "127.0.0.1",
|
|
516
|
-
port: 22,
|
|
517
|
-
username: "root",
|
|
518
|
-
safeDirectory: "/root",
|
|
519
|
-
},
|
|
520
|
-
}, ["dev"]);
|
|
521
|
-
await assert.rejects(() => manager.connect("dev"), (error) => error instanceof ToolError && error.code === "SSH_AUTHENTICATION_MISSING");
|
|
522
|
-
});
|
|
523
|
-
});
|
|
524
|
-
describe("SFTP path validators", () => {
|
|
525
|
-
const manager = SSHConnectionManager.getInstance();
|
|
526
|
-
const baseConfig = (overrides = {}) => ({
|
|
527
|
-
name: "dev",
|
|
528
|
-
host: "127.0.0.1",
|
|
529
|
-
port: 22,
|
|
530
|
-
username: "root",
|
|
531
|
-
password: "test-password",
|
|
532
|
-
...overrides,
|
|
533
|
-
});
|
|
534
|
-
afterEach(() => {
|
|
535
|
-
manager.disconnect();
|
|
536
|
-
manager.setConfig({}, undefined);
|
|
537
|
-
});
|
|
538
|
-
// -------- validateRemotePath --------
|
|
539
|
-
it("should reject SFTP when allowedRemoteDirectories is unset", () => {
|
|
540
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
541
|
-
assert.throws(() => manager.validateRemotePath("/home/test/file.txt", "dev"), (e) => e instanceof ToolError &&
|
|
542
|
-
e.code === "REMOTE_PATH_NOT_ALLOWED" &&
|
|
543
|
-
/no 'allowedRemoteDirectories' configured/.test(e.message));
|
|
544
|
-
});
|
|
545
|
-
it("should reject SFTP when allowedRemoteDirectories is empty", () => {
|
|
546
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: [] }) }, ["dev"]);
|
|
547
|
-
assert.throws(() => manager.validateRemotePath("/home/test/file.txt", "dev"), (e) => e instanceof ToolError && e.code === "REMOTE_PATH_NOT_ALLOWED");
|
|
548
|
-
});
|
|
549
|
-
it("should accept a remote path inside an allowed directory", () => {
|
|
550
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test", "/tmp"] }) }, ["dev"]);
|
|
551
|
-
assert.strictEqual(manager.validateRemotePath("/home/test/sub/file.txt", "dev"), "/home/test/sub/file.txt");
|
|
552
|
-
assert.strictEqual(manager.validateRemotePath("/tmp/file.txt", "dev"), "/tmp/file.txt");
|
|
553
|
-
});
|
|
554
|
-
it("should accept an exact-match remote directory path", () => {
|
|
555
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
556
|
-
assert.strictEqual(manager.validateRemotePath("/home/test", "dev"), "/home/test");
|
|
557
|
-
});
|
|
558
|
-
it("should reject a remote path outside the allowed directories", () => {
|
|
559
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
560
|
-
assert.throws(() => manager.validateRemotePath("/etc/passwd", "dev"), (e) => e instanceof ToolError &&
|
|
561
|
-
e.code === "REMOTE_PATH_NOT_ALLOWED" &&
|
|
562
|
-
/not inside any allowedRemoteDirectories/.test(e.message));
|
|
563
|
-
});
|
|
564
|
-
it("should reject a prefix-match-but-different-directory remote path", () => {
|
|
565
|
-
// "/home/testing/x" must NOT match allowed root "/home/test"
|
|
566
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
567
|
-
assert.throws(() => manager.validateRemotePath("/home/testing/x", "dev"), (e) => e instanceof ToolError && e.code === "REMOTE_PATH_NOT_ALLOWED");
|
|
568
|
-
});
|
|
569
|
-
it("should reject relative remote paths", () => {
|
|
570
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
571
|
-
assert.throws(() => manager.validateRemotePath("home/test/file.txt", "dev"), (e) => e instanceof ToolError &&
|
|
572
|
-
e.code === "REMOTE_PATH_NOT_ALLOWED" &&
|
|
573
|
-
/absolute POSIX path/.test(e.message));
|
|
574
|
-
});
|
|
575
|
-
it("should reject '..' segments in remote paths", () => {
|
|
576
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
577
|
-
assert.throws(() => manager.validateRemotePath("/home/test/../etc/passwd", "dev"), (e) => e instanceof ToolError && e.code === "REMOTE_PATH_NOT_ALLOWED");
|
|
578
|
-
});
|
|
579
|
-
it("should reject null bytes in remote paths", () => {
|
|
580
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/home/test"] }) }, ["dev"]);
|
|
581
|
-
assert.throws(() => manager.validateRemotePath("/home/test/file\0.txt", "dev"), (e) => e instanceof ToolError && e.code === "REMOTE_PATH_NOT_ALLOWED");
|
|
582
|
-
});
|
|
583
|
-
it("should allow any path under root '/' when '/' is in allowedRemoteDirectories", () => {
|
|
584
|
-
manager.setConfig({ dev: baseConfig({ allowedRemoteDirectories: ["/"] }) }, ["dev"]);
|
|
585
|
-
assert.strictEqual(manager.validateRemotePath("/etc/hosts", "dev"), "/etc/hosts");
|
|
586
|
-
});
|
|
587
|
-
// -------- validateLocalPath --------
|
|
588
|
-
it("should accept a local path inside process.cwd() by default", () => {
|
|
589
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
590
|
-
const cwdFile = process.cwd() + (process.platform === "win32" ? "\\test.txt" : "/test.txt");
|
|
591
|
-
assert.strictEqual(manager.validateLocalPath(cwdFile, "dev"), cwdFile);
|
|
592
|
-
});
|
|
593
|
-
it("should accept a local path inside an allowedLocalDirectories entry", () => {
|
|
594
|
-
// Use the temp dir of the OS as an allowed directory
|
|
595
|
-
const tmp = path.resolve(os.tmpdir());
|
|
596
|
-
manager.setConfig({ dev: baseConfig({ allowedLocalDirectories: [tmp] }) }, ["dev"]);
|
|
597
|
-
const file = path.join(tmp, "handfree-test-file.txt");
|
|
598
|
-
assert.strictEqual(manager.validateLocalPath(file, "dev"), file);
|
|
599
|
-
});
|
|
600
|
-
it("should reject a local path outside cwd and outside allowedLocalDirectories", () => {
|
|
601
|
-
const tmp = path.resolve(os.tmpdir());
|
|
602
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]); // no allowedLocalDirectories
|
|
603
|
-
const file = path.join(tmp, "handfree-test-rejected.txt");
|
|
604
|
-
// Skip the case where tmpdir is somehow inside cwd (rare on Windows)
|
|
605
|
-
if (!file.startsWith(process.cwd() + path.sep) && file !== process.cwd()) {
|
|
606
|
-
assert.throws(() => manager.validateLocalPath(file, "dev"), (e) => e instanceof ToolError &&
|
|
607
|
-
e.code === "LOCAL_PATH_NOT_ALLOWED" &&
|
|
608
|
-
/not inside any allowed directory/.test(e.message));
|
|
609
|
-
}
|
|
610
|
-
});
|
|
611
|
-
});
|
|
612
|
-
describe("Upload CRLF auto-fix", () => {
|
|
613
|
-
// Access the private static helper via 'any' for testing.
|
|
614
|
-
const helper = SSHConnectionManager.maybeFixShellScriptLineEndings;
|
|
615
|
-
it("should convert CRLF to LF for .sh files", () => {
|
|
616
|
-
const input = Buffer.from("#!/bin/sh\r\necho hi\r\n", "utf8");
|
|
617
|
-
const result = helper("/x/script.sh", input);
|
|
618
|
-
assert.strictEqual(result.fixed, true);
|
|
619
|
-
assert.strictEqual(result.replacedCount, 2);
|
|
620
|
-
assert.strictEqual(result.buffer.toString("utf8"), "#!/bin/sh\necho hi\n");
|
|
621
|
-
});
|
|
622
|
-
it("should convert CRLF to LF for .bash files", () => {
|
|
623
|
-
const input = Buffer.from("a\r\nb\r\nc", "utf8");
|
|
624
|
-
const result = helper("/x/run.bash", input);
|
|
625
|
-
assert.strictEqual(result.fixed, true);
|
|
626
|
-
assert.strictEqual(result.replacedCount, 2);
|
|
627
|
-
assert.strictEqual(result.buffer.toString("utf8"), "a\nb\nc");
|
|
628
|
-
});
|
|
629
|
-
it("should convert CRLF to LF for .zsh files (case-insensitive)", () => {
|
|
630
|
-
const input = Buffer.from("a\r\nb", "utf8");
|
|
631
|
-
const result = helper("/x/run.ZSH", input);
|
|
632
|
-
assert.strictEqual(result.fixed, true);
|
|
633
|
-
assert.strictEqual(result.replacedCount, 1);
|
|
634
|
-
});
|
|
635
|
-
it("should NOT touch .sh files that already have LF only", () => {
|
|
636
|
-
const input = Buffer.from("#!/bin/sh\necho hi\n", "utf8");
|
|
637
|
-
const result = helper("/x/script.sh", input);
|
|
638
|
-
assert.strictEqual(result.fixed, false);
|
|
639
|
-
assert.strictEqual(result.replacedCount, 0);
|
|
640
|
-
assert.strictEqual(result.buffer, input); // same buffer instance
|
|
641
|
-
});
|
|
642
|
-
it("should NOT touch non-shell-script extensions even with CRLF", () => {
|
|
643
|
-
const input = Buffer.from("a\r\nb\r\n", "utf8");
|
|
644
|
-
const result = helper("/x/data.txt", input);
|
|
645
|
-
assert.strictEqual(result.fixed, false);
|
|
646
|
-
assert.strictEqual(result.replacedCount, 0);
|
|
647
|
-
});
|
|
648
|
-
it("should NOT touch files with no extension", () => {
|
|
649
|
-
const input = Buffer.from("a\r\nb\r\n", "utf8");
|
|
650
|
-
const result = helper("/x/Makefile", input);
|
|
651
|
-
assert.strictEqual(result.fixed, false);
|
|
652
|
-
});
|
|
653
|
-
it("should preserve lone CR (not part of CRLF)", () => {
|
|
654
|
-
const input = Buffer.from("a\rb\r\nc", "utf8");
|
|
655
|
-
const result = helper("/x/run.sh", input);
|
|
656
|
-
assert.strictEqual(result.fixed, true);
|
|
657
|
-
assert.strictEqual(result.replacedCount, 1);
|
|
658
|
-
assert.strictEqual(result.buffer.toString("utf8"), "a\rb\nc");
|
|
659
|
-
});
|
|
660
|
-
it("should preserve binary bytes outside of CRLF sequences", () => {
|
|
661
|
-
const input = Buffer.from([0xff, 0x0d, 0x0a, 0x80, 0x0d, 0x0a]);
|
|
662
|
-
const result = helper("/x/blob.sh", input);
|
|
663
|
-
assert.strictEqual(result.fixed, true);
|
|
664
|
-
assert.strictEqual(result.replacedCount, 2);
|
|
665
|
-
assert.deepStrictEqual(Array.from(result.buffer), [0xff, 0x0a, 0x80, 0x0a]);
|
|
666
|
-
});
|
|
667
|
-
});
|
|
668
|
-
describe("Upload skip-if-identical", () => {
|
|
669
|
-
const manager = SSHConnectionManager.getInstance();
|
|
670
|
-
const baseConfig = (overrides = {}) => ({
|
|
671
|
-
name: "dev",
|
|
672
|
-
host: "127.0.0.1",
|
|
673
|
-
port: 22,
|
|
674
|
-
username: "root",
|
|
675
|
-
password: "test-password",
|
|
676
|
-
allowedRemoteDirectories: ["/tmp"],
|
|
677
|
-
...overrides,
|
|
678
|
-
});
|
|
679
|
-
let tempFile;
|
|
680
|
-
afterEach(() => {
|
|
681
|
-
manager.disconnect();
|
|
682
|
-
manager.setConfig({}, undefined);
|
|
683
|
-
if (tempFile && fsForTest.existsSync(tempFile)) {
|
|
684
|
-
fsForTest.unlinkSync(tempFile);
|
|
685
|
-
}
|
|
686
|
-
});
|
|
687
|
-
function writeLocal(content, ext = ".txt") {
|
|
688
|
-
const name = `handfree-upload-test-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
|
689
|
-
// Place inside cwd so it passes validateLocalPath without extra config
|
|
690
|
-
tempFile = path.resolve(process.cwd(), name);
|
|
691
|
-
fsForTest.writeFileSync(tempFile, content);
|
|
692
|
-
return tempFile;
|
|
693
|
-
}
|
|
694
|
-
it("should skip upload when remote content matches (small file)", async () => {
|
|
695
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
696
|
-
const local = writeLocal(Buffer.from("hello world", "utf8"));
|
|
697
|
-
// Stub: ensureConnected returns a sentinel client; sftp ops return identical bytes
|
|
698
|
-
manager.ensureConnected = async () => ({});
|
|
699
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
700
|
-
manager.sftpStat = async () => ({ size: Buffer.byteLength("hello world") });
|
|
701
|
-
manager.sftpReadBuffer = async () => Buffer.from("hello world", "utf8");
|
|
702
|
-
let writeCalled = false;
|
|
703
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
704
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev");
|
|
705
|
-
assert.match(result, /Upload skipped/);
|
|
706
|
-
assert.match(result, /identical-content/);
|
|
707
|
-
assert.strictEqual(writeCalled, false);
|
|
708
|
-
});
|
|
709
|
-
it("should re-upload when skipIfIdentical=false even if remote matches", async () => {
|
|
710
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
711
|
-
const local = writeLocal(Buffer.from("hello world", "utf8"));
|
|
712
|
-
manager.ensureConnected = async () => ({});
|
|
713
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
714
|
-
manager.sftpStat = async () => ({ size: Buffer.byteLength("hello world") });
|
|
715
|
-
manager.sftpReadBuffer = async () => Buffer.from("hello world", "utf8");
|
|
716
|
-
let writeCalled = false;
|
|
717
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
718
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev", { skipIfIdentical: false });
|
|
719
|
-
assert.match(result, /File uploaded successfully/);
|
|
720
|
-
assert.strictEqual(writeCalled, true);
|
|
721
|
-
});
|
|
722
|
-
it("should upload when remote is missing", async () => {
|
|
723
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
724
|
-
const local = writeLocal(Buffer.from("payload", "utf8"));
|
|
725
|
-
manager.ensureConnected = async () => ({});
|
|
726
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
727
|
-
manager.sftpStat = async () => { throw new ToolError("SFTP_ERROR", "no such file", false); };
|
|
728
|
-
let writeCalled = false;
|
|
729
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
730
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev");
|
|
731
|
-
assert.match(result, /File uploaded successfully/);
|
|
732
|
-
assert.strictEqual(writeCalled, true);
|
|
733
|
-
});
|
|
734
|
-
it("should upload when sizes differ", async () => {
|
|
735
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
736
|
-
const local = writeLocal(Buffer.from("hello", "utf8"));
|
|
737
|
-
manager.ensureConnected = async () => ({});
|
|
738
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
739
|
-
manager.sftpStat = async () => ({ size: 999 });
|
|
740
|
-
let writeCalled = false;
|
|
741
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
742
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev");
|
|
743
|
-
assert.match(result, /File uploaded successfully/);
|
|
744
|
-
assert.strictEqual(writeCalled, true);
|
|
745
|
-
});
|
|
746
|
-
it("should upload when content differs at same size", async () => {
|
|
747
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
748
|
-
const local = writeLocal(Buffer.from("hello", "utf8"));
|
|
749
|
-
manager.ensureConnected = async () => ({});
|
|
750
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
751
|
-
manager.sftpStat = async () => ({ size: 5 });
|
|
752
|
-
manager.sftpReadBuffer = async () => Buffer.from("world", "utf8");
|
|
753
|
-
let writeCalled = false;
|
|
754
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
755
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev");
|
|
756
|
-
assert.match(result, /File uploaded successfully/);
|
|
757
|
-
assert.strictEqual(writeCalled, true);
|
|
758
|
-
});
|
|
759
|
-
it("should report CRLF auto-fix in the response for .sh uploads", async () => {
|
|
760
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
761
|
-
const local = writeLocal(Buffer.from("#!/bin/sh\r\necho hi\r\n", "utf8"), ".sh");
|
|
762
|
-
manager.ensureConnected = async () => ({});
|
|
763
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
764
|
-
manager.sftpStat = async () => { throw new ToolError("SFTP_ERROR", "missing", false); };
|
|
765
|
-
let written = null;
|
|
766
|
-
manager.sftpWriteBuffer = async (_c, _p, payload) => { written = payload; };
|
|
767
|
-
const result = await manager.upload(local, "/tmp/file.sh", "dev");
|
|
768
|
-
assert.match(result, /CRLF.{0,3}LF auto-fix/);
|
|
769
|
-
assert.match(result, /converted 2 line endings/);
|
|
770
|
-
assert.ok(written, "payload should have been written");
|
|
771
|
-
assert.strictEqual(written.toString("utf8"), "#!/bin/sh\necho hi\n");
|
|
772
|
-
});
|
|
773
|
-
it("should reject upload of a directory path", async () => {
|
|
774
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
775
|
-
// Use cwd itself (a directory) as the local path
|
|
776
|
-
const dirPath = process.cwd();
|
|
777
|
-
manager.ensureConnected = async () => ({});
|
|
778
|
-
await assert.rejects(() => manager.upload(dirPath, "/tmp/whatever", "dev"), (e) => e instanceof ToolError &&
|
|
779
|
-
e.code === "LOCAL_FILE_READ_FAILED" &&
|
|
780
|
-
/not a regular file/i.test(e.message));
|
|
781
|
-
});
|
|
782
|
-
});
|
|
783
|
-
describe("Upload shell-script line-ending-agnostic compare", () => {
|
|
784
|
-
// Integration through the real upload() method. The underlying byte-level
|
|
785
|
-
// CRLF→LF normalization is already exercised by "Upload CRLF auto-fix" above,
|
|
786
|
-
// so we only assert the end-to-end skip / re-upload behavior here.
|
|
787
|
-
const manager = SSHConnectionManager.getInstance();
|
|
788
|
-
const baseConfig = (overrides = {}) => ({
|
|
789
|
-
name: "dev",
|
|
790
|
-
host: "127.0.0.1",
|
|
791
|
-
port: 22,
|
|
792
|
-
username: "root",
|
|
793
|
-
password: "test-password",
|
|
794
|
-
allowedRemoteDirectories: ["/tmp"],
|
|
795
|
-
...overrides,
|
|
796
|
-
});
|
|
797
|
-
let tempFile;
|
|
798
|
-
afterEach(() => {
|
|
799
|
-
manager.disconnect();
|
|
800
|
-
manager.setConfig({}, undefined);
|
|
801
|
-
if (tempFile && fsForTest.existsSync(tempFile)) {
|
|
802
|
-
fsForTest.unlinkSync(tempFile);
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
function writeLocal(content, ext) {
|
|
806
|
-
const name = `handfree-shellcmp-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
|
807
|
-
tempFile = path.resolve(process.cwd(), name);
|
|
808
|
-
fsForTest.writeFileSync(tempFile, content);
|
|
809
|
-
return tempFile;
|
|
810
|
-
}
|
|
811
|
-
it("should skip .sh upload when remote has the same content but with CRLF endings", async () => {
|
|
812
|
-
// Local script has LF (or is fixed to LF before compare).
|
|
813
|
-
// Remote currently stores the same logical content but with CRLF.
|
|
814
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
815
|
-
const local = writeLocal(Buffer.from("#!/bin/sh\necho hi\n", "utf8"), ".sh");
|
|
816
|
-
const remoteRaw = Buffer.from("#!/bin/sh\r\necho hi\r\n", "utf8");
|
|
817
|
-
manager.ensureConnected = async () => ({});
|
|
818
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
819
|
-
manager.sftpStat = async () => ({ size: remoteRaw.length });
|
|
820
|
-
manager.sftpReadBuffer = async () => remoteRaw;
|
|
821
|
-
let writeCalled = false;
|
|
822
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
823
|
-
const result = await manager.upload(local, "/tmp/run.sh", "dev");
|
|
824
|
-
assert.match(result, /Upload skipped/);
|
|
825
|
-
assert.match(result, /ignoring-line-endings/);
|
|
826
|
-
assert.strictEqual(writeCalled, false, "should not upload when only line endings differ");
|
|
827
|
-
});
|
|
828
|
-
it("should skip .sh upload when local has CRLF and remote already has LF", async () => {
|
|
829
|
-
// Local is CRLF, will be auto-fixed to LF then compared against an LF remote.
|
|
830
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
831
|
-
const local = writeLocal(Buffer.from("#!/bin/sh\r\necho hi\r\n", "utf8"), ".sh");
|
|
832
|
-
const remoteRaw = Buffer.from("#!/bin/sh\necho hi\n", "utf8");
|
|
833
|
-
manager.ensureConnected = async () => ({});
|
|
834
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
835
|
-
manager.sftpStat = async () => ({ size: remoteRaw.length });
|
|
836
|
-
manager.sftpReadBuffer = async () => remoteRaw;
|
|
837
|
-
let writeCalled = false;
|
|
838
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
839
|
-
const result = await manager.upload(local, "/tmp/run.sh", "dev");
|
|
840
|
-
assert.match(result, /Upload skipped/);
|
|
841
|
-
// CRLF auto-fix should still be noted even though the upload itself was skipped
|
|
842
|
-
assert.match(result, /CRLF.{0,3}LF auto-fix/);
|
|
843
|
-
assert.strictEqual(writeCalled, false);
|
|
844
|
-
});
|
|
845
|
-
it("should re-upload .sh when remote content actually differs (not just line endings)", async () => {
|
|
846
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
847
|
-
const local = writeLocal(Buffer.from("#!/bin/sh\necho hi\n", "utf8"), ".sh");
|
|
848
|
-
const remoteRaw = Buffer.from("#!/bin/sh\r\necho BYE\r\n", "utf8"); // different content
|
|
849
|
-
manager.ensureConnected = async () => ({});
|
|
850
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
851
|
-
manager.sftpStat = async () => ({ size: remoteRaw.length });
|
|
852
|
-
manager.sftpReadBuffer = async () => remoteRaw;
|
|
853
|
-
let writeCalled = false;
|
|
854
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
855
|
-
const result = await manager.upload(local, "/tmp/run.sh", "dev");
|
|
856
|
-
assert.match(result, /File uploaded successfully/);
|
|
857
|
-
assert.strictEqual(writeCalled, true);
|
|
858
|
-
});
|
|
859
|
-
it("should NOT use line-ending-agnostic compare for non-shell files (txt with CRLF differs from LF)", async () => {
|
|
860
|
-
// A .txt that differs only in line endings is NOT skipped — only shell scripts get the agnostic compare.
|
|
861
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
862
|
-
const local = writeLocal(Buffer.from("a\nb\n", "utf8"), ".txt");
|
|
863
|
-
const remoteRaw = Buffer.from("a\r\nb\r\n", "utf8");
|
|
864
|
-
manager.ensureConnected = async () => ({});
|
|
865
|
-
manager.openSftp = async () => ({ end: () => { } });
|
|
866
|
-
manager.sftpStat = async () => ({ size: remoteRaw.length });
|
|
867
|
-
manager.sftpReadBuffer = async () => remoteRaw;
|
|
868
|
-
let writeCalled = false;
|
|
869
|
-
manager.sftpWriteBuffer = async () => { writeCalled = true; };
|
|
870
|
-
const result = await manager.upload(local, "/tmp/file.txt", "dev");
|
|
871
|
-
// Sizes differ (4 vs 6), so we should re-upload
|
|
872
|
-
assert.match(result, /File uploaded successfully/);
|
|
873
|
-
assert.strictEqual(writeCalled, true);
|
|
874
|
-
});
|
|
875
|
-
});
|
|
876
|
-
console.log("\n🧪 Running command validation tests...\n");
|