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,490 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { buildRsyncCommand } from "./rsync";
3
+ import {
4
+ TransferOptions,
5
+ TransferDirection,
6
+ SSHHostConfig,
7
+ } from "../types/server";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ import { statSync } from "fs";
11
+ import type { PathLike } from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
14
+
15
+ vi.mock("fs", async () => {
16
+ const actual = await vi.importActual<typeof import("fs")>("fs");
17
+ return {
18
+ ...actual,
19
+ statSync: vi.fn(),
20
+ };
21
+ });
22
+
23
+ describe("Rsync Command Builder", () => {
24
+ const mockHostConfig: SSHHostConfig = {
25
+ host: "testserver",
26
+ hostName: "example.com",
27
+ user: "testuser",
28
+ port: 22,
29
+ };
30
+
31
+ const configPath = join(homedir(), ".ssh", "config");
32
+
33
+ beforeEach(() => {
34
+ // Mock statSync to return file stats by default
35
+ vi.mocked(statSync).mockImplementation((path: PathLike) => {
36
+ const pathStr = path.toString();
37
+ // Return directory stats for paths ending with "directory" or containing "/dir"
38
+ if (
39
+ pathStr.includes("/directory") ||
40
+ pathStr.endsWith("directory") ||
41
+ pathStr.includes("/dir/") ||
42
+ pathStr === "/local/dir"
43
+ ) {
44
+ return {
45
+ isDirectory: () => true,
46
+ isFile: () => false,
47
+ } as ReturnType<typeof statSync>;
48
+ }
49
+ // Return file stats for other paths
50
+ return {
51
+ isDirectory: () => false,
52
+ isFile: () => true,
53
+ } as ReturnType<typeof statSync>;
54
+ });
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe("buildRsyncCommand", () => {
62
+ it("should construct upload command with correct format", () => {
63
+ const options: TransferOptions = {
64
+ hostConfig: mockHostConfig,
65
+ localPath: "/local/path/file.txt",
66
+ remotePath: "/remote/path/file.txt",
67
+ direction: TransferDirection.UPLOAD,
68
+ };
69
+
70
+ const command = buildRsyncCommand(options);
71
+
72
+ // Paths should be properly escaped
73
+ expect(command).toContain("'/local/path/file.txt'");
74
+ expect(command).toContain("'testserver':");
75
+ expect(command).toContain("'/remote/path/file.txt'");
76
+ expect(command).toMatch(/rsync -e '/);
77
+ });
78
+
79
+ it("should construct download command with correct format", () => {
80
+ const options: TransferOptions = {
81
+ hostConfig: mockHostConfig,
82
+ localPath: "/local/path/destination",
83
+ remotePath: "/remote/path/file.txt",
84
+ direction: TransferDirection.DOWNLOAD,
85
+ };
86
+
87
+ const command = buildRsyncCommand(options);
88
+
89
+ // Paths should be properly escaped
90
+ // For downloads, local destination should have trailing slash to ensure directory is created
91
+ expect(command).toContain("'/local/path/destination/'");
92
+ expect(command).toContain("'testserver':");
93
+ expect(command).toContain("'/remote/path/file.txt'");
94
+ expect(command).toMatch(/rsync -e '/);
95
+ });
96
+
97
+ it("should include archive flag in command", () => {
98
+ const options: TransferOptions = {
99
+ hostConfig: mockHostConfig,
100
+ localPath: "/local/directory",
101
+ remotePath: "/remote/directory",
102
+ direction: TransferDirection.UPLOAD,
103
+ };
104
+
105
+ const command = buildRsyncCommand(options);
106
+
107
+ expect(command).toContain("-a");
108
+ });
109
+
110
+ it("should include SSH config in command", () => {
111
+ const options: TransferOptions = {
112
+ hostConfig: mockHostConfig,
113
+ localPath: "/local/path",
114
+ remotePath: "/remote/path",
115
+ direction: TransferDirection.UPLOAD,
116
+ };
117
+
118
+ const command = buildRsyncCommand(options);
119
+
120
+ // SSH command should be escaped
121
+ expect(command).toMatch(/rsync -e '/);
122
+ expect(command).toContain(configPath);
123
+ });
124
+
125
+ it("should use host alias in command", () => {
126
+ const options: TransferOptions = {
127
+ hostConfig: mockHostConfig,
128
+ localPath: "/local/path",
129
+ remotePath: "/remote/path",
130
+ direction: TransferDirection.UPLOAD,
131
+ };
132
+
133
+ const command = buildRsyncCommand(options);
134
+
135
+ // Host alias should be escaped
136
+ expect(command).toContain("'testserver':");
137
+ });
138
+
139
+ it("should handle different host aliases", () => {
140
+ const customHostConfig: SSHHostConfig = {
141
+ host: "production-server",
142
+ hostName: "prod.example.com",
143
+ };
144
+
145
+ const options: TransferOptions = {
146
+ hostConfig: customHostConfig,
147
+ localPath: "/local/file",
148
+ remotePath: "/remote/file",
149
+ direction: TransferDirection.DOWNLOAD,
150
+ };
151
+
152
+ const command = buildRsyncCommand(options);
153
+
154
+ // Host alias should be escaped
155
+ expect(command).toContain("'production-server':");
156
+ });
157
+
158
+ it("should prevent command injection in localPath", () => {
159
+ const options: TransferOptions = {
160
+ hostConfig: mockHostConfig,
161
+ localPath: "/tmp/test; rm -rf /",
162
+ remotePath: "/remote/path",
163
+ direction: TransferDirection.UPLOAD,
164
+ };
165
+
166
+ const command = buildRsyncCommand(options);
167
+
168
+ // The malicious command should be escaped, not executed
169
+ expect(command).toContain("'/tmp/test; rm -rf /'");
170
+ // The semicolon should be inside single quotes (escaped), not outside
171
+ // Check that the path is properly quoted
172
+ expect(command).toMatch(/'\/(tmp|local)\/test; rm -rf \/'/);
173
+ });
174
+
175
+ it("should prevent command injection in remotePath", () => {
176
+ const options: TransferOptions = {
177
+ hostConfig: mockHostConfig,
178
+ localPath: "/local/path",
179
+ remotePath: "/tmp/test | cat /etc/passwd",
180
+ direction: TransferDirection.UPLOAD,
181
+ };
182
+
183
+ const command = buildRsyncCommand(options);
184
+
185
+ // The malicious command should be escaped
186
+ expect(command).toContain("'/tmp/test | cat /etc/passwd'");
187
+ });
188
+
189
+ it("should prevent command injection in hostAlias", () => {
190
+ const maliciousHostConfig: SSHHostConfig = {
191
+ host: "server; rm -rf /",
192
+ hostName: "example.com",
193
+ };
194
+
195
+ const options: TransferOptions = {
196
+ hostConfig: maliciousHostConfig,
197
+ localPath: "/local/path",
198
+ remotePath: "/remote/path",
199
+ direction: TransferDirection.UPLOAD,
200
+ };
201
+
202
+ const command = buildRsyncCommand(options);
203
+
204
+ // The malicious host alias should be escaped
205
+ expect(command).toContain("'server; rm -rf /':");
206
+ });
207
+
208
+ it("should handle paths with spaces", () => {
209
+ const options: TransferOptions = {
210
+ hostConfig: mockHostConfig,
211
+ localPath: "/local/path with spaces/file.txt",
212
+ remotePath: "/remote/path with spaces/file.txt",
213
+ direction: TransferDirection.UPLOAD,
214
+ };
215
+
216
+ const command = buildRsyncCommand(options);
217
+
218
+ // Paths with spaces should be properly escaped
219
+ expect(command).toContain("'/local/path with spaces/file.txt'");
220
+ expect(command).toContain("'/remote/path with spaces/file.txt'");
221
+ });
222
+
223
+ it("should handle paths with single quotes", () => {
224
+ const options: TransferOptions = {
225
+ hostConfig: mockHostConfig,
226
+ localPath: "/local/file'name.txt",
227
+ remotePath: "/remote/file'name.txt",
228
+ direction: TransferDirection.UPLOAD,
229
+ };
230
+
231
+ const command = buildRsyncCommand(options);
232
+
233
+ // Paths with single quotes should be properly escaped
234
+ expect(command).toContain("'/local/file'\\''name.txt'");
235
+ expect(command).toContain("'/remote/file'\\''name.txt'");
236
+ });
237
+
238
+ it("should include human-readable flag when enabled", () => {
239
+ const options: TransferOptions = {
240
+ hostConfig: mockHostConfig,
241
+ localPath: "/local/path",
242
+ remotePath: "/remote/path",
243
+ direction: TransferDirection.UPLOAD,
244
+ rsyncOptions: {
245
+ humanReadable: true,
246
+ },
247
+ };
248
+
249
+ const command = buildRsyncCommand(options);
250
+
251
+ // Flag should be included in the combined flags string (e.g., -avzh)
252
+ expect(command).toMatch(/-[avz]+h/);
253
+ });
254
+
255
+ it("should include progress flag when enabled", () => {
256
+ const options: TransferOptions = {
257
+ hostConfig: mockHostConfig,
258
+ localPath: "/local/path",
259
+ remotePath: "/remote/path",
260
+ direction: TransferDirection.UPLOAD,
261
+ rsyncOptions: {
262
+ progress: true,
263
+ },
264
+ };
265
+
266
+ const command = buildRsyncCommand(options);
267
+
268
+ // Flag should be included in the combined flags string (e.g., -avzP)
269
+ expect(command).toMatch(/-[avz]+P/);
270
+ });
271
+
272
+ it("should include delete flag when enabled", () => {
273
+ const options: TransferOptions = {
274
+ hostConfig: mockHostConfig,
275
+ localPath: "/local/path",
276
+ remotePath: "/remote/path",
277
+ direction: TransferDirection.UPLOAD,
278
+ rsyncOptions: {
279
+ delete: true,
280
+ },
281
+ };
282
+
283
+ const command = buildRsyncCommand(options);
284
+
285
+ expect(command).toContain("--delete");
286
+ });
287
+
288
+ it("should include all optional flags when all enabled", () => {
289
+ const options: TransferOptions = {
290
+ hostConfig: mockHostConfig,
291
+ localPath: "/local/path",
292
+ remotePath: "/remote/path",
293
+ direction: TransferDirection.DOWNLOAD,
294
+ rsyncOptions: {
295
+ humanReadable: true,
296
+ progress: true,
297
+ delete: true,
298
+ },
299
+ };
300
+
301
+ const command = buildRsyncCommand(options);
302
+
303
+ // Combined short flags should include h and P (e.g., -avzhP)
304
+ expect(command).toMatch(/-[avz]+hP/);
305
+ expect(command).toContain("--delete");
306
+ // Base flags should be present
307
+ expect(command).toMatch(/-[avz]+/);
308
+ });
309
+
310
+ it("should not include optional flags when disabled", () => {
311
+ const options: TransferOptions = {
312
+ hostConfig: mockHostConfig,
313
+ localPath: "/local/path",
314
+ remotePath: "/remote/path",
315
+ direction: TransferDirection.UPLOAD,
316
+ rsyncOptions: {
317
+ humanReadable: false,
318
+ progress: false,
319
+ delete: false,
320
+ },
321
+ };
322
+
323
+ const command = buildRsyncCommand(options);
324
+
325
+ // Extract the flags part (between -e and paths)
326
+ // Note: -e argument is now escaped with single quotes
327
+ const flagsMatch = command.match(/rsync -e '[^']+' (-[^ ]+)/);
328
+ expect(flagsMatch).not.toBeNull();
329
+ const flags = flagsMatch![1];
330
+
331
+ // Optional flags should not be present
332
+ expect(flags).not.toContain("h");
333
+ expect(flags).not.toContain("P");
334
+ expect(command).not.toContain("--delete");
335
+ // Base flags should still be present
336
+ expect(flags).toContain("a");
337
+ expect(flags).toContain("v");
338
+ expect(flags).toContain("z");
339
+ });
340
+
341
+ it("should normalize directory paths for upload - remove trailing slash from source, add to destination", () => {
342
+ const options: TransferOptions = {
343
+ hostConfig: mockHostConfig,
344
+ localPath: "/local/directory",
345
+ remotePath: "/remote/path",
346
+ direction: TransferDirection.UPLOAD,
347
+ };
348
+
349
+ const command = buildRsyncCommand(options);
350
+
351
+ // Source directory should not have trailing slash
352
+ expect(command).toContain("'/local/directory'");
353
+ expect(command).not.toContain("'/local/directory/'");
354
+ // Destination should have trailing slash
355
+ expect(command).toContain("'/remote/path/'");
356
+ });
357
+
358
+ it("should normalize directory paths for upload when source has trailing slash", () => {
359
+ const options: TransferOptions = {
360
+ hostConfig: mockHostConfig,
361
+ localPath: "/local/directory/",
362
+ remotePath: "/remote/path",
363
+ direction: TransferDirection.UPLOAD,
364
+ };
365
+
366
+ const command = buildRsyncCommand(options);
367
+
368
+ // Source directory should not have trailing slash (removed)
369
+ expect(command).toContain("'/local/directory'");
370
+ expect(command).not.toContain("'/local/directory/'");
371
+ // Destination should have trailing slash
372
+ expect(command).toContain("'/remote/path/'");
373
+ });
374
+
375
+ it("should not modify file paths for upload", () => {
376
+ const options: TransferOptions = {
377
+ hostConfig: mockHostConfig,
378
+ localPath: "/local/path/file.txt",
379
+ remotePath: "/remote/path/file.txt",
380
+ direction: TransferDirection.UPLOAD,
381
+ };
382
+
383
+ const command = buildRsyncCommand(options);
384
+
385
+ // File paths should remain unchanged
386
+ expect(command).toContain("'/local/path/file.txt'");
387
+ expect(command).toContain("'/remote/path/file.txt'");
388
+ expect(command).not.toContain("'/remote/path/file.txt/'");
389
+ });
390
+
391
+ it("should normalize paths for download - add trailing slash to local destination, remove from remote source", () => {
392
+ const options: TransferOptions = {
393
+ hostConfig: mockHostConfig,
394
+ localPath: "/local/path/destination",
395
+ remotePath: "/remote/directory",
396
+ direction: TransferDirection.DOWNLOAD,
397
+ };
398
+
399
+ const command = buildRsyncCommand(options);
400
+
401
+ // Local destination should have trailing slash
402
+ expect(command).toContain("'/local/path/destination/'");
403
+ // Remote source should not have trailing slash
404
+ expect(command).toContain("'/remote/directory'");
405
+ expect(command).not.toContain("'/remote/directory/'");
406
+ });
407
+
408
+ it("should normalize paths for download when remote has trailing slash", () => {
409
+ const options: TransferOptions = {
410
+ hostConfig: mockHostConfig,
411
+ localPath: "/local/path/destination",
412
+ remotePath: "/remote/directory/",
413
+ direction: TransferDirection.DOWNLOAD,
414
+ };
415
+
416
+ const command = buildRsyncCommand(options);
417
+
418
+ // Local destination should have trailing slash
419
+ expect(command).toContain("'/local/path/destination/'");
420
+ // Remote source should not have trailing slash (removed)
421
+ expect(command).toContain("'/remote/directory'");
422
+ expect(command).not.toContain("'/remote/directory/'");
423
+ });
424
+
425
+ it("should handle root path correctly", () => {
426
+ const options: TransferOptions = {
427
+ hostConfig: mockHostConfig,
428
+ localPath: "/",
429
+ remotePath: "/remote/path",
430
+ direction: TransferDirection.UPLOAD,
431
+ };
432
+
433
+ const command = buildRsyncCommand(options);
434
+
435
+ // Root path should remain as "/" (not modified)
436
+ expect(command).toContain("'/'");
437
+ });
438
+
439
+ it("should expand tilde in local paths for upload", () => {
440
+ const options: TransferOptions = {
441
+ hostConfig: mockHostConfig,
442
+ localPath: "~/Documents/file.txt",
443
+ remotePath: "/remote/path",
444
+ direction: TransferDirection.UPLOAD,
445
+ };
446
+
447
+ const command = buildRsyncCommand(options);
448
+
449
+ // Tilde should be expanded to home directory (use homedir() to get actual path)
450
+ const expectedPath = path.join(os.homedir(), "Documents/file.txt");
451
+ expect(command).toContain(`'${expectedPath}'`);
452
+ // Should not contain literal ~
453
+ expect(command).not.toContain("'~/");
454
+ });
455
+
456
+ it("should expand tilde in local paths for download", () => {
457
+ const options: TransferOptions = {
458
+ hostConfig: mockHostConfig,
459
+ localPath: "~/Desktop",
460
+ remotePath: "/remote/directory",
461
+ direction: TransferDirection.DOWNLOAD,
462
+ };
463
+
464
+ const command = buildRsyncCommand(options);
465
+
466
+ // Tilde should be expanded to home directory with trailing slash
467
+ const expectedPath = path.join(os.homedir(), "Desktop") + "/";
468
+ expect(command).toContain(`'${expectedPath}'`);
469
+ // Should not contain literal ~
470
+ expect(command).not.toContain("'~/");
471
+ });
472
+
473
+ it("should expand standalone tilde to home directory", () => {
474
+ const options: TransferOptions = {
475
+ hostConfig: mockHostConfig,
476
+ localPath: "~",
477
+ remotePath: "/remote/path",
478
+ direction: TransferDirection.DOWNLOAD,
479
+ };
480
+
481
+ const command = buildRsyncCommand(options);
482
+
483
+ // Standalone tilde should be expanded to home directory with trailing slash
484
+ const expectedPath = os.homedir() + "/";
485
+ expect(command).toContain(`'${expectedPath}'`);
486
+ // Should not contain literal ~
487
+ expect(command).not.toContain("'~'");
488
+ });
489
+ });
490
+ });