spck 0.3.1 → 0.4.0
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/dist/connection/hmac.js +13 -1
- package/dist/proxy/ProxyClient.d.ts +6 -0
- package/dist/proxy/ProxyClient.js +49 -2
- package/dist/services/FilesystemService.d.ts +12 -0
- package/dist/services/FilesystemService.js +136 -24
- package/dist/services/__tests__/FilesystemService.test.js +237 -9
- package/package.json +1 -1
- package/src/connection/hmac.ts +14 -1
- package/src/proxy/ProxyClient.ts +54 -2
- package/src/services/FilesystemService.ts +148 -23
- package/src/services/__tests__/FilesystemService.test.ts +350 -12
package/dist/connection/hmac.js
CHANGED
|
@@ -48,10 +48,22 @@ export function validateHMAC(message, signingKey) {
|
|
|
48
48
|
// Reconstruct the message that was signed (must match client's _computeHMAC)
|
|
49
49
|
// Client uses: const { timestamp, hmac, ...rest } = request
|
|
50
50
|
// So we need to include all fields except timestamp and hmac
|
|
51
|
+
// Strip any Buffer values from params before JSON.stringify,
|
|
52
|
+
// since they serialize inconsistently across environments. Both sides must do this.
|
|
53
|
+
let params = message.params;
|
|
54
|
+
if (params && typeof params === 'object') {
|
|
55
|
+
const cleanParams = {};
|
|
56
|
+
for (const [k, v] of Object.entries(params)) {
|
|
57
|
+
if (!Buffer.isBuffer(v)) {
|
|
58
|
+
cleanParams[k] = v;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
params = cleanParams;
|
|
62
|
+
}
|
|
51
63
|
const payload = {
|
|
52
64
|
jsonrpc: message.jsonrpc,
|
|
53
65
|
method: message.method,
|
|
54
|
-
params
|
|
66
|
+
params,
|
|
55
67
|
id: message.id,
|
|
56
68
|
nonce: message.nonce
|
|
57
69
|
};
|
|
@@ -83,6 +83,12 @@ export declare class ProxyClient {
|
|
|
83
83
|
* Handle RPC message from client
|
|
84
84
|
*/
|
|
85
85
|
private handleRPCMessage;
|
|
86
|
+
/**
|
|
87
|
+
* Stream an async iterable of Buffers to the client using the __binaryChunk protocol.
|
|
88
|
+
* Each chunk is sent as a Socket.IO binary attachment (no base64).
|
|
89
|
+
* The client assembles chunks and resolves the pending RPC request.
|
|
90
|
+
*/
|
|
91
|
+
private sendBinaryChunked;
|
|
86
92
|
/**
|
|
87
93
|
* Send message to client via proxy
|
|
88
94
|
* Automatically chunks large payloads (>800kB)
|
|
@@ -552,11 +552,16 @@ export class ProxyClient {
|
|
|
552
552
|
requireValidHMAC(message, this.connectionSettings.secret);
|
|
553
553
|
// Route to appropriate service with socket wrapper
|
|
554
554
|
const result = await RPCRouter.route(message, connection.socketWrapper);
|
|
555
|
+
// If result is an async iterable, stream it as binary chunks (__binaryChunk protocol)
|
|
556
|
+
if (result != null && typeof result[Symbol.asyncIterator] === 'function') {
|
|
557
|
+
await this.sendBinaryChunked(connectionId, message.id ?? null, result);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
555
560
|
// Send response
|
|
556
561
|
const response = {
|
|
557
562
|
jsonrpc: '2.0',
|
|
558
563
|
result,
|
|
559
|
-
id: message.id
|
|
564
|
+
id: message.id ?? null,
|
|
560
565
|
};
|
|
561
566
|
this.sendToClient(connectionId, 'rpc', response);
|
|
562
567
|
}
|
|
@@ -565,11 +570,53 @@ export class ProxyClient {
|
|
|
565
570
|
const response = {
|
|
566
571
|
jsonrpc: '2.0',
|
|
567
572
|
error: error.code && error.message ? error : createRPCError(ErrorCode.INTERNAL_ERROR, error.message || 'Internal error'),
|
|
568
|
-
id: message.id
|
|
573
|
+
id: message.id ?? null,
|
|
569
574
|
};
|
|
570
575
|
this.sendToClient(connectionId, 'rpc', response);
|
|
571
576
|
}
|
|
572
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* Stream an async iterable of Buffers to the client using the __binaryChunk protocol.
|
|
580
|
+
* Each chunk is sent as a Socket.IO binary attachment (no base64).
|
|
581
|
+
* The client assembles chunks and resolves the pending RPC request.
|
|
582
|
+
*/
|
|
583
|
+
async sendBinaryChunked(connectionId, requestId, iterable) {
|
|
584
|
+
if (!this.socket)
|
|
585
|
+
return;
|
|
586
|
+
const chunkId = crypto.randomBytes(8).toString('hex');
|
|
587
|
+
const total = iterable.totalChunks;
|
|
588
|
+
const sizeMB = iterable.size ? `~${Math.round(iterable.size / 1024 / 1024 * 10) / 10}MB` : '?';
|
|
589
|
+
console.log(`📦 Binary streaming: ${total} chunks (${sizeMB}) for request ${requestId}`);
|
|
590
|
+
try {
|
|
591
|
+
let index = 0;
|
|
592
|
+
for await (const chunk of iterable) {
|
|
593
|
+
// Emit directly (bypass sendToClient/needsChunking) — Socket.IO handles Buffer natively
|
|
594
|
+
this.socket.emit('rpc', {
|
|
595
|
+
connectionId,
|
|
596
|
+
data: {
|
|
597
|
+
__binaryChunk: true,
|
|
598
|
+
chunkId,
|
|
599
|
+
requestId,
|
|
600
|
+
index,
|
|
601
|
+
total,
|
|
602
|
+
data: chunk, // Buffer — Socket.IO sends as binary attachment
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
index++;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
// Stream error — send a normal RPC error so the client rejects the pending request
|
|
610
|
+
this.socket.emit('rpc', {
|
|
611
|
+
connectionId,
|
|
612
|
+
data: {
|
|
613
|
+
jsonrpc: '2.0',
|
|
614
|
+
id: requestId,
|
|
615
|
+
error: createRPCError(ErrorCode.INTERNAL_ERROR, error.message || 'Binary stream error'),
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
573
620
|
/**
|
|
574
621
|
* Send message to client via proxy
|
|
575
622
|
* Automatically chunks large payloads (>800kB)
|
|
@@ -32,6 +32,18 @@ export declare class FilesystemService {
|
|
|
32
32
|
/**
|
|
33
33
|
* Read file contents
|
|
34
34
|
*/
|
|
35
|
+
private stat;
|
|
36
|
+
/**
|
|
37
|
+
* Binary read — returns an async iterable of Buffer chunks.
|
|
38
|
+
* ProxyClient detects the iterable and streams chunks to the client via __binaryChunk protocol.
|
|
39
|
+
* File handle is opened once and held for the full iteration (atomic w.r.t. the file descriptor).
|
|
40
|
+
*/
|
|
41
|
+
private readFileBinary;
|
|
42
|
+
/**
|
|
43
|
+
* Binary write — receives the full buffer as a Socket.IO binary attachment.
|
|
44
|
+
* No accumulation needed; ProxyClient routes directly once Socket.IO reassembles the binary.
|
|
45
|
+
*/
|
|
46
|
+
private writeBinary;
|
|
35
47
|
private readFile;
|
|
36
48
|
/**
|
|
37
49
|
* Write file contents
|
|
@@ -47,6 +47,20 @@ export class FilesystemService {
|
|
|
47
47
|
result = await this.readFile(safePath, params);
|
|
48
48
|
logFsRead(method, params, deviceId, true, undefined, { size: result.size, encoding: result.encoding });
|
|
49
49
|
return result;
|
|
50
|
+
case 'stat':
|
|
51
|
+
result = await this.stat(safePath);
|
|
52
|
+
logFsRead(method, params, deviceId, true, undefined, { size: result.size });
|
|
53
|
+
return result;
|
|
54
|
+
case 'readFileBinary':
|
|
55
|
+
result = await this.readFileBinary(safePath, params);
|
|
56
|
+
logFsRead(method, params, deviceId, true, undefined, result.rangeLength !== undefined
|
|
57
|
+
? { size: result.size, offset: result.rangeOffset, length: result.rangeLength }
|
|
58
|
+
: { size: result.size, totalChunks: result.totalChunks });
|
|
59
|
+
return result;
|
|
60
|
+
case 'writeBinary':
|
|
61
|
+
result = await this.writeBinary(safePath, params);
|
|
62
|
+
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
63
|
+
return result;
|
|
50
64
|
case 'write':
|
|
51
65
|
result = await this.write(safePath, params);
|
|
52
66
|
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
@@ -106,7 +120,7 @@ export class FilesystemService {
|
|
|
106
120
|
catch (err) {
|
|
107
121
|
error = err;
|
|
108
122
|
// Determine if this was a read or write operation for logging
|
|
109
|
-
const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat'];
|
|
123
|
+
const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat', 'stat'];
|
|
110
124
|
if (readOps.includes(method)) {
|
|
111
125
|
logFsRead(method, params, deviceId, false, error);
|
|
112
126
|
}
|
|
@@ -121,8 +135,10 @@ export class FilesystemService {
|
|
|
121
135
|
* Prevents directory traversal and symlink escape attacks
|
|
122
136
|
*/
|
|
123
137
|
async validatePath(userPath) {
|
|
138
|
+
// Strip query string and fragment (cache-busters sent by the browser)
|
|
139
|
+
const cleanPath = userPath.split('?')[0].split('#')[0];
|
|
124
140
|
// Normalize path
|
|
125
|
-
const normalized = path.normalize(
|
|
141
|
+
const normalized = path.normalize(cleanPath);
|
|
126
142
|
// Prevent directory traversal
|
|
127
143
|
if (normalized.includes('..')) {
|
|
128
144
|
throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid path: directory traversal not allowed');
|
|
@@ -220,15 +236,126 @@ export class FilesystemService {
|
|
|
220
236
|
/**
|
|
221
237
|
* Read file contents
|
|
222
238
|
*/
|
|
239
|
+
async stat(safePath) {
|
|
240
|
+
try {
|
|
241
|
+
const stats = await fs.stat(safePath);
|
|
242
|
+
return {
|
|
243
|
+
size: stats.size,
|
|
244
|
+
mtime: stats.mtimeMs,
|
|
245
|
+
isFile: stats.isFile(),
|
|
246
|
+
isDirectory: stats.isDirectory(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === 'ENOENT') {
|
|
251
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Binary read — returns an async iterable of Buffer chunks.
|
|
258
|
+
* ProxyClient detects the iterable and streams chunks to the client via __binaryChunk protocol.
|
|
259
|
+
* File handle is opened once and held for the full iteration (atomic w.r.t. the file descriptor).
|
|
260
|
+
*/
|
|
261
|
+
async readFileBinary(safePath, params) {
|
|
262
|
+
const CHUNK_SIZE = params.chunkSize || 750 * 1024;
|
|
263
|
+
let stats;
|
|
264
|
+
try {
|
|
265
|
+
stats = await fs.stat(safePath);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error.code === 'ENOENT') {
|
|
269
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
const totalSize = stats.size;
|
|
274
|
+
// Range read: single-chunk iterable for a specific byte range (used by video streaming)
|
|
275
|
+
if (params.offset !== undefined) {
|
|
276
|
+
const offset = params.offset;
|
|
277
|
+
const rangeLength = Math.min(params.length !== undefined ? params.length : (totalSize - offset), totalSize - offset);
|
|
278
|
+
if (offset < 0 || rangeLength < 0) {
|
|
279
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
|
|
280
|
+
}
|
|
281
|
+
const iterable = {
|
|
282
|
+
totalChunks: 1,
|
|
283
|
+
size: totalSize,
|
|
284
|
+
rangeOffset: offset,
|
|
285
|
+
rangeLength,
|
|
286
|
+
async *[Symbol.asyncIterator]() {
|
|
287
|
+
const fh = await fs.open(safePath, 'r');
|
|
288
|
+
try {
|
|
289
|
+
const buf = Buffer.alloc(rangeLength);
|
|
290
|
+
if (rangeLength > 0)
|
|
291
|
+
await fh.read(buf, 0, rangeLength, offset);
|
|
292
|
+
yield buf;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
await fh.close();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
return iterable;
|
|
300
|
+
}
|
|
301
|
+
const totalChunks = Math.max(1, Math.ceil(totalSize / CHUNK_SIZE));
|
|
302
|
+
// Return an async iterable. ProxyClient will detect [Symbol.asyncIterator] and stream chunks.
|
|
303
|
+
const iterable = {
|
|
304
|
+
totalChunks,
|
|
305
|
+
size: totalSize,
|
|
306
|
+
async *[Symbol.asyncIterator]() {
|
|
307
|
+
const fh = await fs.open(safePath, 'r');
|
|
308
|
+
try {
|
|
309
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
310
|
+
const offset = i * CHUNK_SIZE;
|
|
311
|
+
const length = Math.min(CHUNK_SIZE, totalSize - offset);
|
|
312
|
+
const buf = Buffer.alloc(length);
|
|
313
|
+
if (length > 0)
|
|
314
|
+
await fh.read(buf, 0, length, offset);
|
|
315
|
+
yield buf;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await fh.close();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
return iterable;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Binary write — receives the full buffer as a Socket.IO binary attachment.
|
|
327
|
+
* No accumulation needed; ProxyClient routes directly once Socket.IO reassembles the binary.
|
|
328
|
+
*/
|
|
329
|
+
async writeBinary(safePath, params) {
|
|
330
|
+
const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.alloc(0);
|
|
331
|
+
return this.write(safePath, { ...params, contents: buffer, encoding: 'binary' });
|
|
332
|
+
}
|
|
223
333
|
async readFile(safePath, params) {
|
|
224
334
|
try {
|
|
225
335
|
const stats = await fs.stat(safePath);
|
|
226
|
-
|
|
336
|
+
const encoding = params.encoding || 'utf8';
|
|
337
|
+
// Range read: bypass maxFileSize limit since we only read a small chunk
|
|
338
|
+
if (params.offset !== undefined) {
|
|
339
|
+
const offset = params.offset;
|
|
340
|
+
const length = Math.min(params.length !== undefined ? params.length : (stats.size - offset), stats.size - offset);
|
|
341
|
+
if (offset < 0 || length <= 0) {
|
|
342
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
|
|
343
|
+
}
|
|
344
|
+
const fh = await fs.open(safePath, 'r');
|
|
345
|
+
try {
|
|
346
|
+
const buf = Buffer.alloc(length);
|
|
347
|
+
await fh.read(buf, 0, length, offset);
|
|
348
|
+
return { buffer: buf, size: stats.size, offset, length, encoding: 'binary' };
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
await fh.close();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Check file size limit for full reads
|
|
227
355
|
const maxSize = parseFileSize(this.config.maxFileSize);
|
|
228
356
|
if (stats.size > maxSize) {
|
|
229
357
|
throw createRPCError(ErrorCode.FILE_TOO_LARGE, `File too large: ${stats.size} bytes (max: ${this.config.maxFileSize})`, { size: stats.size, maxSize });
|
|
230
358
|
}
|
|
231
|
-
const encoding = params.encoding || 'utf8';
|
|
232
359
|
if (encoding === 'binary') {
|
|
233
360
|
// Binary file - return buffer directly in response
|
|
234
361
|
const buffer = await fs.readFile(safePath);
|
|
@@ -280,28 +407,10 @@ export class FilesystemService {
|
|
|
280
407
|
const atomic = params.atomic || false;
|
|
281
408
|
// Write file (atomic or regular)
|
|
282
409
|
if (atomic) {
|
|
283
|
-
|
|
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
|
-
}
|
|
410
|
+
await writeFileAtomic(safePath, params.contents, { encoding });
|
|
293
411
|
}
|
|
294
412
|
else {
|
|
295
|
-
|
|
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
|
-
}
|
|
413
|
+
await fs.writeFile(safePath, params.contents, encoding);
|
|
305
414
|
}
|
|
306
415
|
// Set executable if requested
|
|
307
416
|
if (params.executable) {
|
|
@@ -391,6 +500,9 @@ export class FilesystemService {
|
|
|
391
500
|
*/
|
|
392
501
|
async getFileHash(safePath) {
|
|
393
502
|
const hash = await this.getFileHashValue(safePath);
|
|
503
|
+
if (hash === null) {
|
|
504
|
+
return { hash: null, size: 0, mtime: 0 };
|
|
505
|
+
}
|
|
394
506
|
const stats = await fs.stat(safePath);
|
|
395
507
|
return {
|
|
396
508
|
hash,
|
|
@@ -62,6 +62,16 @@ describe('FilesystemService', () => {
|
|
|
62
62
|
const result = await service.handle('exists', { path: '//test.txt' }, mockSocket);
|
|
63
63
|
expect(result.exists).toBe(true);
|
|
64
64
|
});
|
|
65
|
+
it('should strip query strings from paths (cache-busters)', async () => {
|
|
66
|
+
await fs.writeFile(path.join(testRoot, 'logo.png'), 'data');
|
|
67
|
+
const result = await service.handle('exists', { path: '/logo.png?b=1775829760178' }, mockSocket);
|
|
68
|
+
expect(result.exists).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('should strip URL fragments from paths', async () => {
|
|
71
|
+
await fs.writeFile(path.join(testRoot, 'page.html'), '<html/>');
|
|
72
|
+
const result = await service.handle('exists', { path: '/page.html#section' }, mockSocket);
|
|
73
|
+
expect(result.exists).toBe(true);
|
|
74
|
+
});
|
|
65
75
|
});
|
|
66
76
|
describe('File Operations - exists', () => {
|
|
67
77
|
it('should return true for existing file', async () => {
|
|
@@ -163,19 +173,17 @@ describe('FilesystemService', () => {
|
|
|
163
173
|
}, mockSocket);
|
|
164
174
|
expect(result.success).toBe(true);
|
|
165
175
|
});
|
|
166
|
-
it('should write binary file
|
|
176
|
+
it('should write binary file via writeBinary (Buffer, single chunk)', async () => {
|
|
167
177
|
const bytes = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header
|
|
168
|
-
const
|
|
169
|
-
const result = await service.handle('write', { path: '/image.png', contents: base64, encoding: 'binary' }, mockSocket);
|
|
178
|
+
const result = await service.handle('writeBinary', { path: '/image.png', uploadId: 'upload-1', chunk: 0, totalChunks: 1, data: bytes }, mockSocket);
|
|
170
179
|
expect(result.success).toBe(true);
|
|
171
180
|
expect(result.sha256).toBeTruthy();
|
|
172
181
|
const written = await fs.readFile(path.join(testRoot, 'image.png'));
|
|
173
182
|
expect(written).toEqual(bytes);
|
|
174
183
|
});
|
|
175
|
-
it('should write binary file atomically
|
|
184
|
+
it('should write binary file atomically via writeBinary', async () => {
|
|
176
185
|
const bytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]);
|
|
177
|
-
const
|
|
178
|
-
const result = await service.handle('write', { path: '/data.bin', contents: base64, encoding: 'binary', atomic: true }, mockSocket);
|
|
186
|
+
const result = await service.handle('writeBinary', { path: '/data.bin', uploadId: 'upload-2', chunk: 0, totalChunks: 1, data: bytes, atomic: true }, mockSocket);
|
|
179
187
|
expect(result.success).toBe(true);
|
|
180
188
|
const written = await fs.readFile(path.join(testRoot, 'data.bin'));
|
|
181
189
|
expect(written).toEqual(bytes);
|
|
@@ -186,11 +194,10 @@ describe('FilesystemService', () => {
|
|
|
186
194
|
const written = await fs.readFile(path.join(testRoot, 'empty.bin'));
|
|
187
195
|
expect(written.length).toBe(0);
|
|
188
196
|
});
|
|
189
|
-
it('should preserve all byte values when writing binary via
|
|
197
|
+
it('should preserve all byte values when writing binary via writeBinary', async () => {
|
|
190
198
|
// Full range of byte values 0x00–0xFF
|
|
191
199
|
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
192
|
-
|
|
193
|
-
await service.handle('write', { path: '/allbytes.bin', contents: base64, encoding: 'binary' }, mockSocket);
|
|
200
|
+
await service.handle('writeBinary', { path: '/allbytes.bin', uploadId: 'upload-3', chunk: 0, totalChunks: 1, data: bytes }, mockSocket);
|
|
194
201
|
const written = await fs.readFile(path.join(testRoot, 'allbytes.bin'));
|
|
195
202
|
expect(written).toEqual(bytes);
|
|
196
203
|
});
|
|
@@ -265,6 +272,25 @@ describe('FilesystemService', () => {
|
|
|
265
272
|
expect(result.size).toBe(Buffer.from(content).length);
|
|
266
273
|
expect(result.mtime).toBeGreaterThan(0);
|
|
267
274
|
});
|
|
275
|
+
it('should return null hash when file does not exist', async () => {
|
|
276
|
+
const result = await service.handle('getFileHash', { path: '/nonexistent.txt' }, mockSocket);
|
|
277
|
+
expect(result.hash).toBeNull();
|
|
278
|
+
expect(result.size).toBe(0);
|
|
279
|
+
expect(result.mtime).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
it('should return null hash after file is deleted', async () => {
|
|
282
|
+
await fs.writeFile(path.join(testRoot, 'deleted.txt'), 'some content');
|
|
283
|
+
// Verify hash exists before deletion
|
|
284
|
+
const before = await service.handle('getFileHash', { path: '/deleted.txt' }, mockSocket);
|
|
285
|
+
expect(before.hash).not.toBeNull();
|
|
286
|
+
// Delete the file
|
|
287
|
+
await fs.unlink(path.join(testRoot, 'deleted.txt'));
|
|
288
|
+
// Should gracefully return null hash instead of throwing ENOENT
|
|
289
|
+
const after = await service.handle('getFileHash', { path: '/deleted.txt' }, mockSocket);
|
|
290
|
+
expect(after.hash).toBeNull();
|
|
291
|
+
expect(after.size).toBe(0);
|
|
292
|
+
expect(after.mtime).toBe(0);
|
|
293
|
+
});
|
|
268
294
|
});
|
|
269
295
|
describe('File Operations - remove', () => {
|
|
270
296
|
it('should remove file', async () => {
|
|
@@ -597,6 +623,208 @@ describe('FilesystemService', () => {
|
|
|
597
623
|
});
|
|
598
624
|
});
|
|
599
625
|
});
|
|
626
|
+
describe('File Operations - stat', () => {
|
|
627
|
+
it('should return size, mtime, isFile, isDirectory for a file', async () => {
|
|
628
|
+
const content = 'hello stat';
|
|
629
|
+
await fs.writeFile(path.join(testRoot, 'stat-file.txt'), content);
|
|
630
|
+
const result = await service.handle('stat', { path: '/stat-file.txt' }, mockSocket);
|
|
631
|
+
expect(result.size).toBe(Buffer.byteLength(content));
|
|
632
|
+
expect(result.mtime).toBeGreaterThan(0);
|
|
633
|
+
expect(result.isFile).toBe(true);
|
|
634
|
+
expect(result.isDirectory).toBe(false);
|
|
635
|
+
});
|
|
636
|
+
it('should report isDirectory for a directory', async () => {
|
|
637
|
+
await fs.mkdir(path.join(testRoot, 'statdir'));
|
|
638
|
+
const result = await service.handle('stat', { path: '/statdir' }, mockSocket);
|
|
639
|
+
expect(result.isDirectory).toBe(true);
|
|
640
|
+
expect(result.isFile).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
it('should throw FILE_NOT_FOUND for missing path', async () => {
|
|
643
|
+
await expect(service.handle('stat', { path: '/no-such-file.txt' }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.FILE_NOT_FOUND });
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
describe('File Operations - readFile (range read)', () => {
|
|
647
|
+
let fileContent;
|
|
648
|
+
const FILE = '/range.bin';
|
|
649
|
+
beforeEach(async () => {
|
|
650
|
+
// 100-byte file: byte value equals its index
|
|
651
|
+
fileContent = Buffer.from(Array.from({ length: 100 }, (_, i) => i));
|
|
652
|
+
await fs.writeFile(path.join(testRoot, 'range.bin'), fileContent);
|
|
653
|
+
});
|
|
654
|
+
it('should read a byte range from the middle of the file', async () => {
|
|
655
|
+
const result = await service.handle('readFile', { path: FILE, offset: 10, length: 20 }, mockSocket);
|
|
656
|
+
expect(result.buffer).toEqual(fileContent.subarray(10, 30));
|
|
657
|
+
expect(result.offset).toBe(10);
|
|
658
|
+
expect(result.length).toBe(20);
|
|
659
|
+
expect(result.size).toBe(100);
|
|
660
|
+
expect(result.encoding).toBe('binary');
|
|
661
|
+
});
|
|
662
|
+
it('should read from offset 0', async () => {
|
|
663
|
+
const result = await service.handle('readFile', { path: FILE, offset: 0, length: 5 }, mockSocket);
|
|
664
|
+
expect(result.buffer).toEqual(fileContent.subarray(0, 5));
|
|
665
|
+
});
|
|
666
|
+
it('should read to end of file when length reaches past EOF', async () => {
|
|
667
|
+
// Request 50 bytes starting at offset 80 — only 20 bytes remain
|
|
668
|
+
const result = await service.handle('readFile', { path: FILE, offset: 80, length: 50 }, mockSocket);
|
|
669
|
+
expect(result.buffer).toEqual(fileContent.subarray(80, 100));
|
|
670
|
+
expect(result.length).toBe(20);
|
|
671
|
+
});
|
|
672
|
+
it('should use remaining bytes when length is omitted', async () => {
|
|
673
|
+
const result = await service.handle('readFile', { path: FILE, offset: 90 }, mockSocket);
|
|
674
|
+
expect(result.buffer).toEqual(fileContent.subarray(90));
|
|
675
|
+
expect(result.length).toBe(10);
|
|
676
|
+
});
|
|
677
|
+
it('should bypass the maxFileSize limit for range reads', async () => {
|
|
678
|
+
// Create a file larger than the 10 MB limit
|
|
679
|
+
const bigPath = path.join(testRoot, 'big.bin');
|
|
680
|
+
const bigBuf = Buffer.alloc(11 * 1024 * 1024, 0xab);
|
|
681
|
+
await fs.writeFile(bigPath, bigBuf);
|
|
682
|
+
// Full read should fail with FILE_TOO_LARGE
|
|
683
|
+
await expect(service.handle('readFile', { path: '/big.bin' }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.FILE_TOO_LARGE });
|
|
684
|
+
// Range read of the same file must succeed
|
|
685
|
+
const result = await service.handle('readFile', { path: '/big.bin', offset: 0, length: 64 }, mockSocket);
|
|
686
|
+
expect(result.buffer.length).toBe(64);
|
|
687
|
+
expect(result.size).toBe(bigBuf.length);
|
|
688
|
+
});
|
|
689
|
+
it('should throw INVALID_PARAMS when offset is negative', async () => {
|
|
690
|
+
await expect(service.handle('readFile', { path: FILE, offset: -1, length: 10 }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.INVALID_PARAMS });
|
|
691
|
+
});
|
|
692
|
+
it('should throw INVALID_PARAMS when range produces zero length', async () => {
|
|
693
|
+
// offset at end-of-file → length clamps to 0
|
|
694
|
+
await expect(service.handle('readFile', { path: FILE, offset: 100, length: 10 }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.INVALID_PARAMS });
|
|
695
|
+
});
|
|
696
|
+
it('should throw FILE_NOT_FOUND for range read on missing file', async () => {
|
|
697
|
+
await expect(service.handle('readFile', { path: '/missing.bin', offset: 0, length: 10 }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.FILE_NOT_FOUND });
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
async function collectBinary(iterable) {
|
|
701
|
+
const parts = [];
|
|
702
|
+
for await (const chunk of iterable)
|
|
703
|
+
parts.push(chunk);
|
|
704
|
+
return Buffer.concat(parts);
|
|
705
|
+
}
|
|
706
|
+
describe('File Operations - readFileBinary (async iterable)', () => {
|
|
707
|
+
it('should return async iterable with correct metadata for small file', async () => {
|
|
708
|
+
const content = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
|
|
709
|
+
await fs.writeFile(path.join(testRoot, 'small.png'), content);
|
|
710
|
+
const result = await service.handle('readFileBinary', { path: '/small.png' }, mockSocket);
|
|
711
|
+
expect(result.totalChunks).toBe(1);
|
|
712
|
+
expect(result.size).toBe(4);
|
|
713
|
+
expect(typeof result[Symbol.asyncIterator]).toBe('function');
|
|
714
|
+
expect(await collectBinary(result)).toEqual(content);
|
|
715
|
+
});
|
|
716
|
+
it('should stream multi-chunk file with correct chunk sizes', async () => {
|
|
717
|
+
const CHUNK = 512;
|
|
718
|
+
const content = Buffer.from(Array.from({ length: 1500 }, (_, i) => i % 256));
|
|
719
|
+
await fs.writeFile(path.join(testRoot, 'multi.bin'), content);
|
|
720
|
+
const result = await service.handle('readFileBinary', { path: '/multi.bin', chunkSize: CHUNK }, mockSocket);
|
|
721
|
+
expect(result.totalChunks).toBe(3);
|
|
722
|
+
expect(result.size).toBe(1500);
|
|
723
|
+
const chunks = [];
|
|
724
|
+
for await (const chunk of result) {
|
|
725
|
+
expect(Buffer.isBuffer(chunk)).toBe(true);
|
|
726
|
+
chunks.push(chunk);
|
|
727
|
+
}
|
|
728
|
+
expect(chunks.length).toBe(3);
|
|
729
|
+
expect(Buffer.concat(chunks)).toEqual(content);
|
|
730
|
+
});
|
|
731
|
+
it('should reassemble to exact original bytes', async () => {
|
|
732
|
+
const content = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
733
|
+
await fs.writeFile(path.join(testRoot, 'allbytes2.bin'), content);
|
|
734
|
+
const result = await service.handle('readFileBinary', { path: '/allbytes2.bin', chunkSize: 100 }, mockSocket);
|
|
735
|
+
expect(await collectBinary(result)).toEqual(content);
|
|
736
|
+
});
|
|
737
|
+
it('should handle empty file', async () => {
|
|
738
|
+
await fs.writeFile(path.join(testRoot, 'empty2.bin'), Buffer.alloc(0));
|
|
739
|
+
const result = await service.handle('readFileBinary', { path: '/empty2.bin' }, mockSocket);
|
|
740
|
+
expect(result.size).toBe(0);
|
|
741
|
+
expect(result.totalChunks).toBe(1);
|
|
742
|
+
expect((await collectBinary(result)).length).toBe(0);
|
|
743
|
+
});
|
|
744
|
+
it('should throw FILE_NOT_FOUND for missing file', async () => {
|
|
745
|
+
await expect(service.handle('readFileBinary', { path: '/missing.bin' }, mockSocket)).rejects.toMatchObject({ code: ErrorCode.FILE_NOT_FOUND });
|
|
746
|
+
});
|
|
747
|
+
it('should support concurrent reads of the same file without interference', async () => {
|
|
748
|
+
const content = Buffer.from(Array.from({ length: 300 }, (_, i) => i % 256));
|
|
749
|
+
await fs.writeFile(path.join(testRoot, 'concurrent.bin'), content);
|
|
750
|
+
// Start two reads concurrently
|
|
751
|
+
const [r1, r2] = await Promise.all([
|
|
752
|
+
service.handle('readFileBinary', { path: '/concurrent.bin', chunkSize: 100 }, mockSocket),
|
|
753
|
+
service.handle('readFileBinary', { path: '/concurrent.bin', chunkSize: 150 }, mockSocket),
|
|
754
|
+
]);
|
|
755
|
+
const [buf1, buf2] = await Promise.all([collectBinary(r1), collectBinary(r2)]);
|
|
756
|
+
expect(buf1).toEqual(content);
|
|
757
|
+
expect(buf2).toEqual(content);
|
|
758
|
+
});
|
|
759
|
+
it('should return sha256-verifiable content matching the written file', async () => {
|
|
760
|
+
const content = Buffer.from('binary content for hash check');
|
|
761
|
+
await fs.writeFile(path.join(testRoot, 'hashcheck.bin'), content);
|
|
762
|
+
const expected = crypto.createHash('sha256').update(content).digest('hex');
|
|
763
|
+
const result = await service.handle('readFileBinary', { path: '/hashcheck.bin' }, mockSocket);
|
|
764
|
+
const assembled = await collectBinary(result);
|
|
765
|
+
const actual = crypto.createHash('sha256').update(assembled).digest('hex');
|
|
766
|
+
expect(actual).toBe(expected);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
describe('File Operations - writeBinary', () => {
|
|
770
|
+
it('should write binary Buffer directly', async () => {
|
|
771
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
|
772
|
+
const result = await service.handle('writeBinary', { path: '/out.png', data: bytes }, mockSocket);
|
|
773
|
+
expect(result.success).toBe(true);
|
|
774
|
+
expect(result.sha256).toBeTruthy();
|
|
775
|
+
expect(await fs.readFile(path.join(testRoot, 'out.png'))).toEqual(bytes);
|
|
776
|
+
});
|
|
777
|
+
it('should write all byte values correctly', async () => {
|
|
778
|
+
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
779
|
+
await service.handle('writeBinary', { path: '/allbytes3.bin', data: bytes }, mockSocket);
|
|
780
|
+
expect(await fs.readFile(path.join(testRoot, 'allbytes3.bin'))).toEqual(bytes);
|
|
781
|
+
});
|
|
782
|
+
it('should write atomically when requested', async () => {
|
|
783
|
+
const bytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xFF]);
|
|
784
|
+
const result = await service.handle('writeBinary', { path: '/atomic.bin', data: bytes, atomic: true }, mockSocket);
|
|
785
|
+
expect(result.success).toBe(true);
|
|
786
|
+
expect(await fs.readFile(path.join(testRoot, 'atomic.bin'))).toEqual(bytes);
|
|
787
|
+
});
|
|
788
|
+
it('should return sha256 of written content', async () => {
|
|
789
|
+
const bytes = Buffer.from([0x01, 0x02, 0x03, 0x04]);
|
|
790
|
+
const expected = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
791
|
+
const result = await service.handle('writeBinary', { path: '/sha.bin', data: bytes }, mockSocket);
|
|
792
|
+
expect(result.sha256).toBe(expected);
|
|
793
|
+
});
|
|
794
|
+
it('should overwrite existing file', async () => {
|
|
795
|
+
await fs.writeFile(path.join(testRoot, 'overwrite.bin'), Buffer.from([0xFF, 0xFF]));
|
|
796
|
+
const newBytes = Buffer.from([0x01, 0x02, 0x03]);
|
|
797
|
+
await service.handle('writeBinary', { path: '/overwrite.bin', data: newBytes }, mockSocket);
|
|
798
|
+
expect(await fs.readFile(path.join(testRoot, 'overwrite.bin'))).toEqual(newBytes);
|
|
799
|
+
});
|
|
800
|
+
it('should detect write conflict when expectedHash does not match', async () => {
|
|
801
|
+
const original = Buffer.from([0xAA, 0xBB]);
|
|
802
|
+
await fs.writeFile(path.join(testRoot, 'conflict.bin'), original);
|
|
803
|
+
await expect(service.handle('writeBinary', {
|
|
804
|
+
path: '/conflict.bin',
|
|
805
|
+
data: Buffer.from([0x01]),
|
|
806
|
+
expectedHash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
|
807
|
+
}, mockSocket)).rejects.toMatchObject({ code: ErrorCode.WRITE_CONFLICT });
|
|
808
|
+
});
|
|
809
|
+
it('should write when expectedHash matches current file', async () => {
|
|
810
|
+
const original = Buffer.from([0xAA, 0xBB, 0xCC]);
|
|
811
|
+
await fs.writeFile(path.join(testRoot, 'match.bin'), original);
|
|
812
|
+
const correctHash = crypto.createHash('sha256').update(original).digest('hex');
|
|
813
|
+
const result = await service.handle('writeBinary', {
|
|
814
|
+
path: '/match.bin',
|
|
815
|
+
data: Buffer.from([0x11, 0x22]),
|
|
816
|
+
expectedHash: correctHash,
|
|
817
|
+
}, mockSocket);
|
|
818
|
+
expect(result.success).toBe(true);
|
|
819
|
+
});
|
|
820
|
+
it('round-trip: writeBinary then readFileBinary returns identical bytes', async () => {
|
|
821
|
+
const bytes = Buffer.from(Array.from({ length: 512 }, (_, i) => (i * 7 + 13) % 256));
|
|
822
|
+
await service.handle('writeBinary', { path: '/roundtrip.bin', data: bytes }, mockSocket);
|
|
823
|
+
const iterable = await service.handle('readFileBinary', { path: '/roundtrip.bin', chunkSize: 200 }, mockSocket);
|
|
824
|
+
const readBack = await collectBinary(iterable);
|
|
825
|
+
expect(readBack).toEqual(bytes);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
600
828
|
describe('Unknown Methods', () => {
|
|
601
829
|
it('should throw error for unknown method', async () => {
|
|
602
830
|
await expect(service.handle('unknownMethod', {}, mockSocket)).rejects.toMatchObject({
|