spck 0.3.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/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem service - handles file operations with fossil-delta compression
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import * as fsSync from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as crypto from 'crypto';
|
|
8
|
+
import fossilDelta from 'fossil-delta';
|
|
9
|
+
import writeFileAtomic from 'write-file-atomic';
|
|
10
|
+
import { ErrorCode, createRPCError } from '../types.js';
|
|
11
|
+
import { parseFileSize } from '../config/config.js';
|
|
12
|
+
import { logFsRead, logFsWrite } from '../utils/logger.js';
|
|
13
|
+
export class FilesystemService {
|
|
14
|
+
constructor(rootPath, config) {
|
|
15
|
+
this.rootPath = rootPath;
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.realRootPath = null;
|
|
18
|
+
// Resolve rootPath symlinks once during construction
|
|
19
|
+
// This ensures consistent path comparisons in validatePath
|
|
20
|
+
this.resolvedRootPath = path.resolve(rootPath);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get the real root path (following symlinks), cached after first call
|
|
24
|
+
*/
|
|
25
|
+
async getRealRootPath() {
|
|
26
|
+
if (!this.realRootPath) {
|
|
27
|
+
this.realRootPath = await fs.realpath(this.rootPath);
|
|
28
|
+
}
|
|
29
|
+
return this.realRootPath;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Handle filesystem RPC methods
|
|
33
|
+
*/
|
|
34
|
+
async handle(method, params, socket) {
|
|
35
|
+
const deviceId = socket.data.deviceId;
|
|
36
|
+
let result;
|
|
37
|
+
let error;
|
|
38
|
+
try {
|
|
39
|
+
// Validate and sandbox path
|
|
40
|
+
const safePath = params.path ? await this.validatePath(params.path) : undefined;
|
|
41
|
+
switch (method) {
|
|
42
|
+
case 'exists':
|
|
43
|
+
result = await this.exists(safePath);
|
|
44
|
+
logFsRead(method, params, deviceId, true);
|
|
45
|
+
return result;
|
|
46
|
+
case 'readFile':
|
|
47
|
+
result = await this.readFile(safePath, params);
|
|
48
|
+
logFsRead(method, params, deviceId, true, undefined, { size: result.size, encoding: result.encoding });
|
|
49
|
+
return result;
|
|
50
|
+
case 'write':
|
|
51
|
+
result = await this.write(safePath, params);
|
|
52
|
+
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
53
|
+
return result;
|
|
54
|
+
case 'patchFile':
|
|
55
|
+
result = await this.patchFile(safePath, params);
|
|
56
|
+
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
57
|
+
return result;
|
|
58
|
+
case 'getFileHash':
|
|
59
|
+
result = await this.getFileHash(safePath);
|
|
60
|
+
logFsRead(method, params, deviceId, true, undefined, { hash: result.hash });
|
|
61
|
+
return result;
|
|
62
|
+
case 'remove':
|
|
63
|
+
result = await this.remove(safePath);
|
|
64
|
+
logFsWrite(method, params, deviceId, true);
|
|
65
|
+
return result;
|
|
66
|
+
case 'mkdir':
|
|
67
|
+
result = await this.mkdir(safePath, false);
|
|
68
|
+
logFsWrite(method, params, deviceId, true);
|
|
69
|
+
return result;
|
|
70
|
+
case 'mkdirp':
|
|
71
|
+
result = await this.mkdir(safePath, true);
|
|
72
|
+
logFsWrite(method, params, deviceId, true);
|
|
73
|
+
return result;
|
|
74
|
+
case 'readdir':
|
|
75
|
+
result = await this.readdir(safePath, params);
|
|
76
|
+
logFsRead(method, params, deviceId, true, undefined, { count: result.entries.length });
|
|
77
|
+
return result;
|
|
78
|
+
case 'readdirDeep':
|
|
79
|
+
result = await this.readdirDeep(safePath, params);
|
|
80
|
+
logFsRead(method, params, deviceId, true, undefined, { count: result.length });
|
|
81
|
+
return result;
|
|
82
|
+
case 'bulkExists':
|
|
83
|
+
result = await this.bulkExists(safePath, params);
|
|
84
|
+
logFsRead(method, params, deviceId, true, undefined, { count: result.length });
|
|
85
|
+
return result;
|
|
86
|
+
case 'lstat':
|
|
87
|
+
result = await this.lstat(safePath);
|
|
88
|
+
logFsRead(method, params, deviceId, true, undefined, { isFile: result.isFile, isDirectory: result.isDirectory });
|
|
89
|
+
return result;
|
|
90
|
+
case 'mv':
|
|
91
|
+
result = await this.mv(await this.validatePath(params.src), await this.validatePath(params.target), params.opts);
|
|
92
|
+
logFsWrite(method, params, deviceId, true, undefined, { type: result });
|
|
93
|
+
return result;
|
|
94
|
+
case 'copy':
|
|
95
|
+
result = await this.copy(await this.validatePath(params.oldpath), safePath, params.opts);
|
|
96
|
+
logFsWrite(method, params, deviceId, true, undefined, { type: result });
|
|
97
|
+
return result;
|
|
98
|
+
case 'rmdir':
|
|
99
|
+
result = await this.rmdir(safePath);
|
|
100
|
+
logFsWrite(method, params, deviceId, true);
|
|
101
|
+
return result;
|
|
102
|
+
default:
|
|
103
|
+
throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: fs.${method}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
error = err;
|
|
108
|
+
// Determine if this was a read or write operation for logging
|
|
109
|
+
const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat'];
|
|
110
|
+
if (readOps.includes(method)) {
|
|
111
|
+
logFsRead(method, params, deviceId, false, error);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
logFsWrite(method, params, deviceId, false, error);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Validate and sandbox path with symlink resolution
|
|
121
|
+
* Prevents directory traversal and symlink escape attacks
|
|
122
|
+
*/
|
|
123
|
+
async validatePath(userPath) {
|
|
124
|
+
// Normalize path
|
|
125
|
+
const normalized = path.normalize(userPath);
|
|
126
|
+
// Prevent directory traversal
|
|
127
|
+
if (normalized.includes('..')) {
|
|
128
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid path: directory traversal not allowed');
|
|
129
|
+
}
|
|
130
|
+
// Resolve to absolute path
|
|
131
|
+
const absolute = path.resolve(this.rootPath, normalized.startsWith('/') ? normalized.slice(1) : normalized);
|
|
132
|
+
// Check if within root (before symlink resolution)
|
|
133
|
+
if (!absolute.startsWith(this.resolvedRootPath)) {
|
|
134
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: path outside root directory');
|
|
135
|
+
}
|
|
136
|
+
// Resolve symlinks and verify they stay within root
|
|
137
|
+
try {
|
|
138
|
+
// Try to resolve the path (follows all symlinks including in rootPath)
|
|
139
|
+
const realPath = await fs.realpath(absolute);
|
|
140
|
+
// Get real root path (cached, in case rootPath itself contains symlinks like /tmp -> /private/tmp)
|
|
141
|
+
const realRoot = await this.getRealRootPath();
|
|
142
|
+
// Check resolved path is still within resolved root
|
|
143
|
+
if (!realPath.startsWith(realRoot)) {
|
|
144
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: symlink points outside root directory');
|
|
145
|
+
}
|
|
146
|
+
return realPath;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Path doesn't exist - validate parent directory instead
|
|
150
|
+
if (error.code === 'ENOENT') {
|
|
151
|
+
const parentDir = path.dirname(absolute);
|
|
152
|
+
try {
|
|
153
|
+
const parentReal = await fs.realpath(parentDir);
|
|
154
|
+
const realRoot = await this.getRealRootPath();
|
|
155
|
+
// Check parent directory is within root
|
|
156
|
+
if (!parentReal.startsWith(realRoot)) {
|
|
157
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: parent directory symlink points outside root');
|
|
158
|
+
}
|
|
159
|
+
// Return original absolute path (not parent)
|
|
160
|
+
return absolute;
|
|
161
|
+
}
|
|
162
|
+
catch (parentError) {
|
|
163
|
+
// Parent doesn't exist either - allow for mkdirp operations
|
|
164
|
+
// Just validate the normalized path structure
|
|
165
|
+
return absolute;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Re-throw validation errors
|
|
169
|
+
if (error.code === ErrorCode.INVALID_PATH) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
// For other errors, continue with original path
|
|
173
|
+
return absolute;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check if file/directory exists
|
|
178
|
+
*/
|
|
179
|
+
async exists(safePath) {
|
|
180
|
+
try {
|
|
181
|
+
await fs.access(safePath);
|
|
182
|
+
return { exists: true };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { exists: false };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Check existence of multiple paths in parallel
|
|
190
|
+
*/
|
|
191
|
+
async bulkExists(basePath, params) {
|
|
192
|
+
const { paths } = params;
|
|
193
|
+
if (!Array.isArray(paths)) {
|
|
194
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'paths must be an array');
|
|
195
|
+
}
|
|
196
|
+
// basePath is already validated/resolved by validatePath in handle()
|
|
197
|
+
const realRoot = await this.getRealRootPath();
|
|
198
|
+
// Validate and check all paths in parallel
|
|
199
|
+
// Return 1/0 instead of true/false for bandwidth efficiency
|
|
200
|
+
const results = await Promise.all(paths.map(async (relativePath) => {
|
|
201
|
+
try {
|
|
202
|
+
// Normalize relative path and prevent traversal
|
|
203
|
+
const normalized = path.normalize(relativePath);
|
|
204
|
+
if (normalized.includes('..'))
|
|
205
|
+
return 0;
|
|
206
|
+
// Resolve against validated base path
|
|
207
|
+
const absolute = path.resolve(basePath, normalized);
|
|
208
|
+
// Ensure still within root
|
|
209
|
+
if (!absolute.startsWith(realRoot))
|
|
210
|
+
return 0;
|
|
211
|
+
await fs.access(absolute);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
}));
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Read file contents
|
|
222
|
+
*/
|
|
223
|
+
async readFile(safePath, params) {
|
|
224
|
+
try {
|
|
225
|
+
const stats = await fs.stat(safePath);
|
|
226
|
+
// Check file size limit
|
|
227
|
+
const maxSize = parseFileSize(this.config.maxFileSize);
|
|
228
|
+
if (stats.size > maxSize) {
|
|
229
|
+
throw createRPCError(ErrorCode.FILE_TOO_LARGE, `File too large: ${stats.size} bytes (max: ${this.config.maxFileSize})`, { size: stats.size, maxSize });
|
|
230
|
+
}
|
|
231
|
+
const encoding = params.encoding || 'utf8';
|
|
232
|
+
if (encoding === 'binary') {
|
|
233
|
+
// Binary file - return buffer directly in response
|
|
234
|
+
const buffer = await fs.readFile(safePath);
|
|
235
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
236
|
+
return {
|
|
237
|
+
buffer,
|
|
238
|
+
size: stats.size,
|
|
239
|
+
mtime: stats.mtimeMs,
|
|
240
|
+
encoding: 'binary',
|
|
241
|
+
sha256,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
// Text file
|
|
246
|
+
const contents = await fs.readFile(safePath, encoding);
|
|
247
|
+
const contentsStr = typeof contents === 'string' ? contents : contents.toString('utf8');
|
|
248
|
+
const sha256 = crypto.createHash('sha256').update(contentsStr, 'utf8').digest('hex');
|
|
249
|
+
return {
|
|
250
|
+
contents,
|
|
251
|
+
size: stats.size,
|
|
252
|
+
mtime: stats.mtimeMs,
|
|
253
|
+
encoding,
|
|
254
|
+
sha256,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error.code === 'ENOENT') {
|
|
260
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Write file contents
|
|
267
|
+
*/
|
|
268
|
+
async write(safePath, params) {
|
|
269
|
+
// Check if expectedHash provided (conflict detection)
|
|
270
|
+
if (params.expectedHash) {
|
|
271
|
+
const currentHash = await this.getFileHashValue(safePath);
|
|
272
|
+
if (currentHash && currentHash !== params.expectedHash) {
|
|
273
|
+
throw createRPCError(ErrorCode.WRITE_CONFLICT, 'File was modified on server since last read', {
|
|
274
|
+
expectedHash: params.expectedHash,
|
|
275
|
+
currentHash,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const encoding = params.encoding || 'utf8';
|
|
280
|
+
const atomic = params.atomic || false;
|
|
281
|
+
// Write file (atomic or regular)
|
|
282
|
+
if (atomic) {
|
|
283
|
+
// Use write-file-atomic for atomic writes
|
|
284
|
+
if (encoding === 'binary') {
|
|
285
|
+
const buffer = typeof params.contents === 'string'
|
|
286
|
+
? Buffer.from(params.contents, 'base64')
|
|
287
|
+
: Buffer.from(params.contents || Buffer.alloc(0));
|
|
288
|
+
await writeFileAtomic(safePath, buffer);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await writeFileAtomic(safePath, params.contents, { encoding });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Regular write
|
|
296
|
+
if (encoding === 'binary') {
|
|
297
|
+
const buffer = typeof params.contents === 'string'
|
|
298
|
+
? Buffer.from(params.contents, 'base64')
|
|
299
|
+
: Buffer.from(params.contents || Buffer.alloc(0));
|
|
300
|
+
await fs.writeFile(safePath, buffer);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
await fs.writeFile(safePath, params.contents, encoding);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Set executable if requested
|
|
307
|
+
if (params.executable) {
|
|
308
|
+
await fs.chmod(safePath, 0o755);
|
|
309
|
+
}
|
|
310
|
+
// Return metadata
|
|
311
|
+
const stats = await fs.stat(safePath);
|
|
312
|
+
// Check total file size after write to prevent bypass via multiple writes
|
|
313
|
+
const maxSize = parseFileSize(this.config.maxFileSize);
|
|
314
|
+
if (stats.size > maxSize) {
|
|
315
|
+
// Rollback: delete the oversized file
|
|
316
|
+
try {
|
|
317
|
+
await fs.unlink(safePath);
|
|
318
|
+
}
|
|
319
|
+
catch (unlinkError) {
|
|
320
|
+
// Ignore unlink errors (file might be locked)
|
|
321
|
+
}
|
|
322
|
+
throw createRPCError(ErrorCode.FILE_TOO_LARGE, `File exceeds maximum size after write: ${stats.size} bytes (max: ${this.config.maxFileSize})`, { size: stats.size, maxSize });
|
|
323
|
+
}
|
|
324
|
+
const contents = await fs.readFile(safePath);
|
|
325
|
+
const sha256 = crypto.createHash('sha256').update(contents).digest('hex');
|
|
326
|
+
return {
|
|
327
|
+
success: true,
|
|
328
|
+
mtime: stats.mtimeMs,
|
|
329
|
+
size: stats.size,
|
|
330
|
+
sha256,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Apply fossil-delta patch to file
|
|
335
|
+
*/
|
|
336
|
+
async patchFile(safePath, params) {
|
|
337
|
+
try {
|
|
338
|
+
// Read current file
|
|
339
|
+
const currentContents = await fs.readFile(safePath);
|
|
340
|
+
// Verify base hash
|
|
341
|
+
const currentHash = crypto.createHash('sha256').update(currentContents).digest('hex');
|
|
342
|
+
if (currentHash !== params.baseHash) {
|
|
343
|
+
throw createRPCError(ErrorCode.WRITE_CONFLICT, 'Base hash mismatch - file was modified', {
|
|
344
|
+
expectedHash: params.baseHash,
|
|
345
|
+
currentHash,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Apply fossil-delta patch
|
|
349
|
+
const deltaBuffer = Buffer.from(params.delta);
|
|
350
|
+
const patchedResult = fossilDelta.apply(currentContents, deltaBuffer);
|
|
351
|
+
// Convert to Buffer if it's an array
|
|
352
|
+
const patchedContents = Buffer.isBuffer(patchedResult)
|
|
353
|
+
? patchedResult
|
|
354
|
+
: Buffer.from(patchedResult);
|
|
355
|
+
// Verify final hash
|
|
356
|
+
const finalHash = crypto.createHash('sha256').update(patchedContents).digest('hex');
|
|
357
|
+
// Check if delta resulted in expected content (80% efficiency check happens client-side)
|
|
358
|
+
if (params.newHash && finalHash !== params.newHash) {
|
|
359
|
+
throw createRPCError(ErrorCode.DELTA_PATCH_FAILED, 'Final hash mismatch - patch resulted in unexpected content', {
|
|
360
|
+
expectedHash: params.newHash,
|
|
361
|
+
actualHash: finalHash,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// Write file (atomic or regular)
|
|
365
|
+
const atomic = params.atomic || false;
|
|
366
|
+
const encoding = params.encoding || 'utf8';
|
|
367
|
+
if (atomic) {
|
|
368
|
+
await writeFileAtomic(safePath, patchedContents, { encoding });
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
await fs.writeFile(safePath, patchedContents, encoding);
|
|
372
|
+
}
|
|
373
|
+
// Return metadata
|
|
374
|
+
const stats = await fs.stat(safePath);
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
finalHash,
|
|
378
|
+
size: stats.size,
|
|
379
|
+
mtime: stats.mtimeMs,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (error.code && error.message) {
|
|
384
|
+
throw error; // Re-throw RPC errors
|
|
385
|
+
}
|
|
386
|
+
throw createRPCError(ErrorCode.DELTA_PATCH_FAILED, `Failed to apply delta patch: ${error.message}`, { reason: error.toString() });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Get file hash
|
|
391
|
+
*/
|
|
392
|
+
async getFileHash(safePath) {
|
|
393
|
+
const hash = await this.getFileHashValue(safePath);
|
|
394
|
+
const stats = await fs.stat(safePath);
|
|
395
|
+
return {
|
|
396
|
+
hash,
|
|
397
|
+
size: stats.size,
|
|
398
|
+
mtime: stats.mtimeMs,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Get file hash value (internal)
|
|
403
|
+
*/
|
|
404
|
+
async getFileHashValue(safePath) {
|
|
405
|
+
try {
|
|
406
|
+
const contents = await fs.readFile(safePath);
|
|
407
|
+
return crypto.createHash('sha256').update(contents).digest('hex');
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (error.code === 'ENOENT') {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Remove file or directory
|
|
418
|
+
* Returns success even if file doesn't exist (idempotent operation)
|
|
419
|
+
*/
|
|
420
|
+
async remove(safePath) {
|
|
421
|
+
try {
|
|
422
|
+
const stats = await fs.stat(safePath);
|
|
423
|
+
if (stats.isDirectory()) {
|
|
424
|
+
await fs.rm(safePath, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
await fs.unlink(safePath);
|
|
428
|
+
}
|
|
429
|
+
return { success: true };
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error.code === 'ENOENT') {
|
|
433
|
+
// File doesn't exist - treat as already removed (idempotent)
|
|
434
|
+
return { success: true };
|
|
435
|
+
}
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Create directory
|
|
441
|
+
*/
|
|
442
|
+
async mkdir(safePath, recursive) {
|
|
443
|
+
await fs.mkdir(safePath, { recursive });
|
|
444
|
+
return { success: true };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Read directory contents
|
|
448
|
+
*/
|
|
449
|
+
async readdir(safePath, params) {
|
|
450
|
+
try {
|
|
451
|
+
const entries = await fs.readdir(safePath);
|
|
452
|
+
const result = [];
|
|
453
|
+
const ignoreSet = new Set(['.git', '.spck-editor', '.DS_Store']);
|
|
454
|
+
for (const name of entries) {
|
|
455
|
+
if (ignoreSet.has(name))
|
|
456
|
+
continue;
|
|
457
|
+
const entryPath = path.join(safePath, name);
|
|
458
|
+
const stats = await fs.stat(entryPath);
|
|
459
|
+
// Skip based on filters
|
|
460
|
+
if (params.skipFiles && stats.isFile())
|
|
461
|
+
continue;
|
|
462
|
+
else if (params.skipFolders && stats.isDirectory())
|
|
463
|
+
continue;
|
|
464
|
+
// Return just the name string, not the object
|
|
465
|
+
result.push(name);
|
|
466
|
+
}
|
|
467
|
+
return { entries: result };
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (error.code === 'ENOENT') {
|
|
471
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
|
|
472
|
+
}
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Read directory recursively using breadth-first (level-order) traversal
|
|
478
|
+
* Performance optimizations:
|
|
479
|
+
* - matchPattern: Regex to filter paths (applied to full relative path)
|
|
480
|
+
* - limit: Maximum number of results (files + folders combined)
|
|
481
|
+
*
|
|
482
|
+
* Breadth-first ensures top-level items are returned first, which is important
|
|
483
|
+
* when using the limit parameter to get a representative sample of the directory.
|
|
484
|
+
*/
|
|
485
|
+
async readdirDeep(safePath, params) {
|
|
486
|
+
try {
|
|
487
|
+
const includeFiles = params.files !== false; // Default true
|
|
488
|
+
const includeFolders = params.folders !== false; // Default true
|
|
489
|
+
const limit = params.limit !== undefined && params.limit !== null
|
|
490
|
+
? parseInt(params.limit, 10)
|
|
491
|
+
: null;
|
|
492
|
+
// Compile regex pattern if provided (case-insensitive)
|
|
493
|
+
let matchRegex = null;
|
|
494
|
+
if (params.matchPattern) {
|
|
495
|
+
try {
|
|
496
|
+
matchRegex = new RegExp(params.matchPattern, 'i');
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, `Invalid matchPattern regex: ${error.message}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Parse ignoreName into a Set
|
|
503
|
+
const ignoreSet = new Set(['.git', '.spck-editor']);
|
|
504
|
+
if (params.ignoreName && typeof params.ignoreName === 'string') {
|
|
505
|
+
params.ignoreName.split(':').forEach((name) => {
|
|
506
|
+
if (name.trim()) {
|
|
507
|
+
ignoreSet.add(name.trim());
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const results = [];
|
|
512
|
+
// Early exit if limit is 0
|
|
513
|
+
if (limit === 0) {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
// Verify initial directory exists before starting traversal
|
|
517
|
+
try {
|
|
518
|
+
const stats = await fs.stat(safePath);
|
|
519
|
+
if (!stats.isDirectory()) {
|
|
520
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Not a directory: ${safePath}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
if (error.code === 'ENOENT') {
|
|
525
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
|
|
526
|
+
}
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
// Get real root path for relative path calculations
|
|
530
|
+
// (safePath might be resolved real path if it was a symlink)
|
|
531
|
+
const realRoot = await this.getRealRootPath();
|
|
532
|
+
// Breadth-first traversal using a queue
|
|
533
|
+
let queue = [safePath];
|
|
534
|
+
while (queue.length > 0 && (limit === null || results.length < limit)) {
|
|
535
|
+
// Process current level
|
|
536
|
+
const currentLevel = queue;
|
|
537
|
+
queue = []; // New queue for next level
|
|
538
|
+
for (const currentPath of currentLevel) {
|
|
539
|
+
// Early exit if limit reached
|
|
540
|
+
if (limit !== null && results.length >= limit) {
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
let entries;
|
|
544
|
+
try {
|
|
545
|
+
entries = await fs.readdir(currentPath);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
// Skip subdirectories we can't read (but initial dir should have been validated)
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
for (const name of entries) {
|
|
552
|
+
// Early exit if limit reached
|
|
553
|
+
if (limit !== null && results.length >= limit) {
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
// Skip ignored names
|
|
557
|
+
if (ignoreSet.has(name)) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const entryPath = path.join(currentPath, name);
|
|
561
|
+
let stats;
|
|
562
|
+
try {
|
|
563
|
+
stats = await fs.stat(entryPath);
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
// Skip entries we can't stat
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (stats.isDirectory()) {
|
|
570
|
+
// Add to next level queue
|
|
571
|
+
queue.push(entryPath);
|
|
572
|
+
// Convert to relative path using real root
|
|
573
|
+
const outputPath = path.relative(realRoot, entryPath);
|
|
574
|
+
// Apply filter and check limit for folders
|
|
575
|
+
if (includeFolders) {
|
|
576
|
+
const matches = !matchRegex || matchRegex.test(outputPath);
|
|
577
|
+
if (matches) {
|
|
578
|
+
results.push(outputPath);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else if (stats.isFile() && includeFiles) {
|
|
583
|
+
// Convert to relative path using real root
|
|
584
|
+
const outputPath = path.relative(realRoot, entryPath);
|
|
585
|
+
// Apply filter and check limit for files
|
|
586
|
+
const matches = !matchRegex || matchRegex.test(outputPath);
|
|
587
|
+
if (matches) {
|
|
588
|
+
results.push(outputPath);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return results;
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
if (error.code === 'ENOENT') {
|
|
598
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
|
|
599
|
+
}
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Get file metadata
|
|
605
|
+
*/
|
|
606
|
+
async lstat(safePath) {
|
|
607
|
+
try {
|
|
608
|
+
const stats = await fs.lstat(safePath);
|
|
609
|
+
return {
|
|
610
|
+
mode: stats.mode,
|
|
611
|
+
size: stats.size,
|
|
612
|
+
mtimeMs: stats.mtimeMs,
|
|
613
|
+
ctimeMs: stats.ctimeMs,
|
|
614
|
+
atimeMs: stats.atimeMs,
|
|
615
|
+
isFile: stats.isFile(),
|
|
616
|
+
isDirectory: stats.isDirectory(),
|
|
617
|
+
isSymbolicLink: stats.isSymbolicLink(),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
if (error.code === 'ENOENT') {
|
|
622
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
623
|
+
}
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Move/rename file or directory
|
|
629
|
+
* Returns the type of the moved item ('file' or 'folder')
|
|
630
|
+
*/
|
|
631
|
+
async mv(srcPath, targetPath, opts = {}) {
|
|
632
|
+
try {
|
|
633
|
+
// Check source type before moving
|
|
634
|
+
const srcStats = await fs.stat(srcPath);
|
|
635
|
+
const type = srcStats.isDirectory() ? 'folder' : 'file';
|
|
636
|
+
// Ensure target directory exists
|
|
637
|
+
const targetDir = path.dirname(targetPath);
|
|
638
|
+
try {
|
|
639
|
+
await fs.access(targetDir);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (error.code === 'ENOENT') {
|
|
643
|
+
// Create target directory if it doesn't exist
|
|
644
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Check if target exists
|
|
648
|
+
if (!opts.overwrite) {
|
|
649
|
+
try {
|
|
650
|
+
await fs.access(targetPath);
|
|
651
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Target already exists and overwrite is false');
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (error.code !== 'ENOENT')
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
await fs.rename(srcPath, targetPath);
|
|
659
|
+
return type;
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (error.code === 'ENOENT') {
|
|
663
|
+
// If this is a deletion (move to trash) and source doesn't exist, treat as already deleted
|
|
664
|
+
if (opts.deletion) {
|
|
665
|
+
return 'file'; // Return default type for deleted files
|
|
666
|
+
}
|
|
667
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Source file not found: ${srcPath} (attempting to move to ${targetPath})`);
|
|
668
|
+
}
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Copy file or directory
|
|
674
|
+
* Returns the type of the copied item ('file' or 'folder')
|
|
675
|
+
*/
|
|
676
|
+
async copy(oldPath, newPath, opts = {}) {
|
|
677
|
+
try {
|
|
678
|
+
// Check source type before copying
|
|
679
|
+
const srcStats = await fs.stat(oldPath);
|
|
680
|
+
const type = srcStats.isDirectory() ? 'folder' : 'file';
|
|
681
|
+
if (srcStats.isDirectory()) {
|
|
682
|
+
// Copy directory recursively
|
|
683
|
+
await this.copyDirectory(oldPath, newPath, opts);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
// Copy file
|
|
687
|
+
const flags = opts.overwrite ? 0 : fsSync.constants.COPYFILE_EXCL;
|
|
688
|
+
await fs.copyFile(oldPath, newPath, flags);
|
|
689
|
+
}
|
|
690
|
+
return type;
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error.code === 'ENOENT') {
|
|
694
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Source file not found: ${oldPath}`);
|
|
695
|
+
}
|
|
696
|
+
if (error.code === 'EEXIST') {
|
|
697
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Target already exists and overwrite is false');
|
|
698
|
+
}
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Copy directory recursively (helper method)
|
|
704
|
+
*/
|
|
705
|
+
async copyDirectory(srcDir, destDir, opts = {}) {
|
|
706
|
+
// Create destination directory
|
|
707
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
708
|
+
// Read source directory
|
|
709
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
710
|
+
for (const entry of entries) {
|
|
711
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
712
|
+
const destPath = path.join(destDir, entry.name);
|
|
713
|
+
if (entry.isDirectory()) {
|
|
714
|
+
// Recursively copy subdirectory
|
|
715
|
+
await this.copyDirectory(srcPath, destPath, opts);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
// Copy file
|
|
719
|
+
const flags = opts.overwrite ? 0 : fsSync.constants.COPYFILE_EXCL;
|
|
720
|
+
await fs.copyFile(srcPath, destPath, flags);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Remove directory
|
|
726
|
+
* Returns success even if directory doesn't exist (idempotent operation)
|
|
727
|
+
*/
|
|
728
|
+
async rmdir(safePath) {
|
|
729
|
+
try {
|
|
730
|
+
await fs.rmdir(safePath);
|
|
731
|
+
return { success: true };
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (error.code === 'ENOENT') {
|
|
735
|
+
// Directory doesn't exist - treat as already removed (idempotent)
|
|
736
|
+
return { success: true };
|
|
737
|
+
}
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
//# sourceMappingURL=FilesystemService.js.map
|