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,212 @@
1
+ import { SSHHostConfig } from "../types/server";
2
+ import * as os from "node:os";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+
6
+ // Cache for parsed SSH config
7
+ interface CacheEntry {
8
+ hosts: SSHHostConfig[];
9
+ mtimeMs: number;
10
+ }
11
+
12
+ let configCache: CacheEntry | null = null;
13
+
14
+ /**
15
+ * Get the absolute path to the SSH config file
16
+ * @returns The path to ~/.ssh/config
17
+ */
18
+ export function getSSHConfigPath(): string {
19
+ return path.join(os.homedir(), ".ssh", "config");
20
+ }
21
+
22
+ /**
23
+ * Clear the SSH config cache
24
+ */
25
+ export function clearCache(): void {
26
+ configCache = null;
27
+ }
28
+
29
+ /**
30
+ * Parse SSH config content string and extract host configurations
31
+ * @param content The SSH config file content
32
+ * @returns Array of SSHHostConfig objects (one per host alias)
33
+ */
34
+ export function parseConfigContent(content: string): SSHHostConfig[] {
35
+ const hosts: SSHHostConfig[] = [];
36
+ const lines = content.split("\n");
37
+
38
+ let currentHosts: string[] = [];
39
+ let currentConfig: Partial<SSHHostConfig> = {};
40
+
41
+ for (let i = 0; i < lines.length; i++) {
42
+ const line = lines[i];
43
+ const trimmedLine = line.trim();
44
+
45
+ // Skip empty lines and comments
46
+ if (!trimmedLine || trimmedLine.startsWith("#")) {
47
+ continue;
48
+ }
49
+
50
+ // Check if this is a Host line
51
+ const hostMatch = trimmedLine.match(/^Host\s+(.+)$/i);
52
+ if (hostMatch) {
53
+ // Save previous host config if exists
54
+ if (currentHosts.length > 0) {
55
+ saveHostConfigs(hosts, currentHosts, currentConfig);
56
+ }
57
+
58
+ // Parse new host aliases (space-separated)
59
+ const aliases = hostMatch[1].split(/\s+/).filter((alias) => alias.trim());
60
+
61
+ // Filter out wildcard hosts
62
+ currentHosts = aliases.filter(
63
+ (alias) => alias !== "*" && !alias.includes("*"),
64
+ );
65
+ currentConfig = {};
66
+ continue;
67
+ }
68
+
69
+ // Parse host properties (indented lines)
70
+ if (currentHosts.length > 0) {
71
+ const propertyMatch = trimmedLine.match(/^(\w+)\s+(.+)$/);
72
+ if (propertyMatch) {
73
+ const [, key, value] = propertyMatch;
74
+ const lowerKey = key.toLowerCase();
75
+
76
+ try {
77
+ switch (lowerKey) {
78
+ case "hostname": {
79
+ currentConfig.hostName = value.trim();
80
+ break;
81
+ }
82
+ case "user": {
83
+ currentConfig.user = value.trim();
84
+ break;
85
+ }
86
+ case "port": {
87
+ const portNum = parseInt(value.trim(), 10);
88
+ if (!isNaN(portNum)) {
89
+ currentConfig.port = portNum;
90
+ } else {
91
+ console.warn(`Invalid port value on line ${i + 1}: ${value}`);
92
+ }
93
+ break;
94
+ }
95
+ case "identityfile": {
96
+ // Expand ~ in paths
97
+ let identityPath = value.trim();
98
+ if (identityPath.startsWith("~")) {
99
+ identityPath = path.join(
100
+ os.homedir(),
101
+ identityPath.substring(1),
102
+ );
103
+ }
104
+ currentConfig.identityFile = identityPath;
105
+ break;
106
+ }
107
+ case "proxyjump": {
108
+ currentConfig.proxyJump = value.trim();
109
+ break;
110
+ }
111
+ }
112
+ } catch (error) {
113
+ // Log malformed entry but continue parsing
114
+ console.warn(`Skipping malformed entry on line ${i + 1}:`, error);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // Save the last host config
121
+ if (currentHosts.length > 0) {
122
+ saveHostConfigs(hosts, currentHosts, currentConfig);
123
+ }
124
+
125
+ return hosts;
126
+ }
127
+
128
+ /**
129
+ * Parse the SSH config file and extract host configurations
130
+ * Uses caching based on file modification time
131
+ * @returns Array of SSHHostConfig objects (one per host alias), or empty array if file doesn't exist or cannot be read
132
+ */
133
+ export function parseSSHConfig(): SSHHostConfig[] {
134
+ const configPath = getSSHConfigPath();
135
+
136
+ // Check if file exists
137
+ if (!fs.existsSync(configPath)) {
138
+ return [];
139
+ }
140
+
141
+ try {
142
+ // Check file modification time for cache invalidation
143
+ const stats = fs.statSync(configPath);
144
+ const mtimeMs = stats.mtimeMs;
145
+
146
+ // Return cached result if cache is valid
147
+ if (configCache && configCache.mtimeMs === mtimeMs) {
148
+ return configCache.hosts;
149
+ }
150
+
151
+ // Read the config file
152
+ const content = fs.readFileSync(configPath, "utf-8");
153
+
154
+ // Parse the content
155
+ const hosts = parseConfigContent(content);
156
+
157
+ // Update cache
158
+ configCache = {
159
+ hosts,
160
+ mtimeMs,
161
+ };
162
+
163
+ return hosts;
164
+ } catch (error) {
165
+ // Handle read errors gracefully - return empty array
166
+ const nodeError = error as NodeJS.ErrnoException;
167
+ console.error("Error reading SSH config file:", {
168
+ code: nodeError.code,
169
+ message: nodeError.message,
170
+ path: configPath,
171
+ });
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Helper function to save host configurations
178
+ * Creates one SSHHostConfig object per host alias
179
+ */
180
+ function saveHostConfigs(
181
+ hosts: SSHHostConfig[],
182
+ aliases: string[],
183
+ config: Partial<SSHHostConfig>,
184
+ ): void {
185
+ for (const alias of aliases) {
186
+ hosts.push({
187
+ host: alias,
188
+ hostName: config.hostName,
189
+ user: config.user,
190
+ port: config.port,
191
+ identityFile: config.identityFile,
192
+ proxyJump: config.proxyJump,
193
+ });
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Find a specific host configuration by alias
199
+ * @param alias The host alias to search for
200
+ * @returns The SSHHostConfig object or null if not found
201
+ */
202
+ export function getHostConfig(alias: string): SSHHostConfig | null {
203
+ try {
204
+ const hosts = parseSSHConfig();
205
+ return hosts.find((host) => host.host === alias) || null;
206
+ } catch (error) {
207
+ // Log error for debugging
208
+ console.error("Error getting host config:", error);
209
+ // Return null if config cannot be parsed
210
+ return null;
211
+ }
212
+ }
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import {
4
+ validateLocalPath,
5
+ validateRemotePath,
6
+ validatePort,
7
+ validateHostConfig,
8
+ } from "./validation";
9
+ import { SSHHostConfig } from "../types/server";
10
+
11
+ // Type definitions for mocked fs module
12
+ type MockedFS = typeof fs & {
13
+ __setMockFileExists: (exists: boolean) => void;
14
+ };
15
+
16
+ // Mock fs module
17
+ vi.mock("node:fs", async () => {
18
+ const actual = await vi.importActual<typeof fs>("node:fs");
19
+ let mockFileExists = false;
20
+
21
+ return {
22
+ ...actual,
23
+ existsSync: vi.fn(() => mockFileExists),
24
+ __setMockFileExists: (exists: boolean) => {
25
+ mockFileExists = exists;
26
+ },
27
+ };
28
+ });
29
+
30
+ describe("Validation Utilities", () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ (fs as unknown as MockedFS).__setMockFileExists(false);
34
+ });
35
+
36
+ describe("validateLocalPath", () => {
37
+ it("should return valid for existing file", () => {
38
+ (fs as unknown as MockedFS).__setMockFileExists(true);
39
+ const result = validateLocalPath("/path/to/file.txt");
40
+ expect(result.valid).toBe(true);
41
+ expect(result.error).toBeUndefined();
42
+ });
43
+
44
+ it("should return invalid for non-existent file", () => {
45
+ (fs as unknown as MockedFS).__setMockFileExists(false);
46
+ const result = validateLocalPath("/path/to/nonexistent.txt");
47
+ expect(result.valid).toBe(false);
48
+ expect(result.error).toBe("File not found");
49
+ });
50
+
51
+ it("should return invalid for empty path", () => {
52
+ const result = validateLocalPath("");
53
+ expect(result.valid).toBe(false);
54
+ expect(result.error).toBe("Local path cannot be empty");
55
+ });
56
+
57
+ it("should return invalid for whitespace-only path", () => {
58
+ const result = validateLocalPath(" ");
59
+ expect(result.valid).toBe(false);
60
+ expect(result.error).toBe("Local path cannot be empty");
61
+ });
62
+ });
63
+
64
+ describe("validateRemotePath", () => {
65
+ it("should return valid for valid path format", () => {
66
+ const result = validateRemotePath("/home/user/file.txt");
67
+ expect(result.valid).toBe(true);
68
+ expect(result.error).toBeUndefined();
69
+ });
70
+
71
+ it("should return valid for relative path", () => {
72
+ const result = validateRemotePath("./documents/file.txt");
73
+ expect(result.valid).toBe(true);
74
+ expect(result.error).toBeUndefined();
75
+ });
76
+
77
+ it("should return invalid for empty path", () => {
78
+ const result = validateRemotePath("");
79
+ expect(result.valid).toBe(false);
80
+ expect(result.error).toBe("Remote path cannot be empty");
81
+ });
82
+
83
+ it("should return invalid for path with control characters", () => {
84
+ const result = validateRemotePath("/path/with\x00null");
85
+ expect(result.valid).toBe(false);
86
+ expect(result.error).toContain("Invalid path format");
87
+ });
88
+
89
+ it("should return invalid for path with shell metacharacters (semicolon)", () => {
90
+ const result = validateRemotePath("/tmp/test; rm -rf /");
91
+ expect(result.valid).toBe(false);
92
+ expect(result.error).toContain("shell metacharacters");
93
+ });
94
+
95
+ it("should return invalid for path with shell metacharacters (pipe)", () => {
96
+ const result = validateRemotePath("/tmp/test | cat");
97
+ expect(result.valid).toBe(false);
98
+ expect(result.error).toContain("shell metacharacters");
99
+ });
100
+
101
+ it("should return invalid for path with shell metacharacters (ampersand)", () => {
102
+ const result = validateRemotePath("/tmp/test & echo");
103
+ expect(result.valid).toBe(false);
104
+ expect(result.error).toContain("shell metacharacters");
105
+ });
106
+
107
+ it("should return invalid for path with shell metacharacters (backtick)", () => {
108
+ const result = validateRemotePath("/tmp/test`whoami`");
109
+ expect(result.valid).toBe(false);
110
+ expect(result.error).toContain("shell metacharacters");
111
+ });
112
+
113
+ it("should return invalid for path with shell metacharacters (dollar sign)", () => {
114
+ const result = validateRemotePath("/tmp/test$HOME");
115
+ expect(result.valid).toBe(false);
116
+ expect(result.error).toContain("shell metacharacters");
117
+ });
118
+
119
+ it("should allow parentheses in paths (legitimate filename characters)", () => {
120
+ const result = validateRemotePath("/tmp/test(file)");
121
+ // Parentheses are allowed as they can legitimately appear in filenames
122
+ // and are safely handled by our escaping
123
+ expect(result.valid).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe("validatePort", () => {
128
+ it("should return valid for port in valid range", () => {
129
+ const result = validatePort(22);
130
+ expect(result.valid).toBe(true);
131
+ expect(result.error).toBeUndefined();
132
+ });
133
+
134
+ it("should return valid for port 1", () => {
135
+ const result = validatePort(1);
136
+ expect(result.valid).toBe(true);
137
+ });
138
+
139
+ it("should return valid for port 65535", () => {
140
+ const result = validatePort(65535);
141
+ expect(result.valid).toBe(true);
142
+ });
143
+
144
+ it("should return invalid for port 0", () => {
145
+ const result = validatePort(0);
146
+ expect(result.valid).toBe(false);
147
+ expect(result.error).toBe(
148
+ "Invalid port number: must be between 1 and 65535",
149
+ );
150
+ });
151
+
152
+ it("should return invalid for port above 65535", () => {
153
+ const result = validatePort(65536);
154
+ expect(result.valid).toBe(false);
155
+ expect(result.error).toBe(
156
+ "Invalid port number: must be between 1 and 65535",
157
+ );
158
+ });
159
+
160
+ it("should return invalid for negative port", () => {
161
+ const result = validatePort(-1);
162
+ expect(result.valid).toBe(false);
163
+ expect(result.error).toBe(
164
+ "Invalid port number: must be between 1 and 65535",
165
+ );
166
+ });
167
+
168
+ it("should return invalid for non-integer port", () => {
169
+ const result = validatePort(22.5);
170
+ expect(result.valid).toBe(false);
171
+ expect(result.error).toBe("Port must be an integer");
172
+ });
173
+ });
174
+
175
+ describe("validateHostConfig", () => {
176
+ it("should return valid for complete config", () => {
177
+ const config: SSHHostConfig = {
178
+ host: "server1",
179
+ hostName: "example.com",
180
+ user: "admin",
181
+ port: 22,
182
+ };
183
+ const result = validateHostConfig(config);
184
+ expect(result.valid).toBe(true);
185
+ expect(result.error).toBeUndefined();
186
+ });
187
+
188
+ it("should return valid for config without optional fields", () => {
189
+ const config: SSHHostConfig = {
190
+ host: "server1",
191
+ };
192
+ const result = validateHostConfig(config);
193
+ expect(result.valid).toBe(true);
194
+ });
195
+
196
+ it("should return invalid for missing host alias", () => {
197
+ const config: SSHHostConfig = {
198
+ host: "",
199
+ hostName: "example.com",
200
+ };
201
+ const result = validateHostConfig(config);
202
+ expect(result.valid).toBe(false);
203
+ expect(result.error).toBe("Host alias is required");
204
+ });
205
+
206
+ it("should return invalid for invalid port", () => {
207
+ const config: SSHHostConfig = {
208
+ host: "server1",
209
+ port: 70000,
210
+ };
211
+ const result = validateHostConfig(config);
212
+ expect(result.valid).toBe(false);
213
+ expect(result.error).toBe(
214
+ "Invalid port number: must be between 1 and 65535",
215
+ );
216
+ });
217
+
218
+ it("should return invalid for null config", () => {
219
+ const result = validateHostConfig(null as unknown as SSHHostConfig);
220
+ expect(result.valid).toBe(false);
221
+ expect(result.error).toBe("Host configuration is required");
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,115 @@
1
+ import { existsSync } from "fs";
2
+ import { SSHHostConfig } from "../types/server";
3
+
4
+ /**
5
+ * Validation result structure
6
+ */
7
+ export interface ValidationResult {
8
+ valid: boolean;
9
+ error?: string;
10
+ }
11
+
12
+ /**
13
+ * Validates that a local path exists on the filesystem
14
+ * @param path - The local file or directory path to validate
15
+ * @returns Validation result with error message if invalid
16
+ */
17
+ export function validateLocalPath(path: string): ValidationResult {
18
+ if (!path || path.trim() === "") {
19
+ return { valid: false, error: "Local path cannot be empty" };
20
+ }
21
+
22
+ if (!existsSync(path)) {
23
+ return { valid: false, error: "File not found" };
24
+ }
25
+
26
+ return { valid: true };
27
+ }
28
+
29
+ /**
30
+ * Validates remote path format
31
+ * @param path - The remote path to validate
32
+ * @returns Validation result with error message if invalid
33
+ */
34
+ export function validateRemotePath(path: string): ValidationResult {
35
+ if (!path || path.trim() === "") {
36
+ return { valid: false, error: "Remote path cannot be empty" };
37
+ }
38
+
39
+ // Check for invalid characters that could cause issues
40
+ // eslint-disable-next-line no-control-regex
41
+ const invalidChars = /[\x00-\x1F\x7F]/;
42
+ if (invalidChars.test(path)) {
43
+ return {
44
+ valid: false,
45
+ error: "Invalid path format: contains control characters",
46
+ };
47
+ }
48
+
49
+ // Defense in depth: Detect dangerous shell metacharacters that could indicate injection attempts
50
+ // Note: While we now properly escape all paths, this validation provides early detection
51
+ // of potentially malicious input. We only reject clearly dangerous characters:
52
+ // - ; (command separator)
53
+ // - | (pipe)
54
+ // - & (background execution)
55
+ // - ` (command substitution)
56
+ // - $ (variable expansion)
57
+ // - \ (escape character)
58
+ // We allow parentheses, brackets, and braces as they can legitimately appear in filenames
59
+ // and are safely handled by our escaping.
60
+ const dangerousMetacharacters = /[;&|`$\\]/;
61
+ if (dangerousMetacharacters.test(path)) {
62
+ return {
63
+ valid: false,
64
+ error:
65
+ "Invalid path format: contains dangerous shell metacharacters. Paths are now properly escaped, but this input may be unsafe.",
66
+ };
67
+ }
68
+
69
+ return { valid: true };
70
+ }
71
+
72
+ /**
73
+ * Validates that a port number is within valid range (1-65535)
74
+ * @param port - The port number to validate
75
+ * @returns Validation result with error message if invalid
76
+ */
77
+ export function validatePort(port: number): ValidationResult {
78
+ if (!Number.isInteger(port)) {
79
+ return { valid: false, error: "Port must be an integer" };
80
+ }
81
+
82
+ if (port < 1 || port > 65535) {
83
+ return {
84
+ valid: false,
85
+ error: "Invalid port number: must be between 1 and 65535",
86
+ };
87
+ }
88
+
89
+ return { valid: true };
90
+ }
91
+
92
+ /**
93
+ * Validates that a host configuration has required fields and valid values
94
+ * @param config - The SSH host configuration to validate
95
+ * @returns Validation result with error message if invalid
96
+ */
97
+ export function validateHostConfig(config: SSHHostConfig): ValidationResult {
98
+ if (!config) {
99
+ return { valid: false, error: "Host configuration is required" };
100
+ }
101
+
102
+ if (!config.host || config.host.trim() === "") {
103
+ return { valid: false, error: "Host alias is required" };
104
+ }
105
+
106
+ // Validate port if present
107
+ if (config.port !== undefined) {
108
+ const portValidation = validatePort(config.port);
109
+ if (!portValidation.valid) {
110
+ return portValidation;
111
+ }
112
+ }
113
+
114
+ return { valid: true };
115
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2021",
4
+ "module": "commonjs",
5
+ "lib": ["ES2021"],
6
+ "jsx": "react",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "moduleResolution": "node",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "declaration": true,
16
+ "declarationMap": true,
17
+ "sourceMap": true,
18
+ "outDir": "./dist",
19
+ "rootDir": "./src",
20
+ "baseUrl": ".",
21
+ "paths": {
22
+ "@/*": ["src/*"]
23
+ }
24
+ },
25
+ "include": ["src/**/*"],
26
+ "exclude": ["node_modules", "dist"]
27
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ },
8
+ });