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