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/src/connection/hmac.ts
CHANGED
|
@@ -55,10 +55,23 @@ export function validateHMAC(message: JSONRPCRequest, signingKey: string): boole
|
|
|
55
55
|
// Reconstruct the message that was signed (must match client's _computeHMAC)
|
|
56
56
|
// Client uses: const { timestamp, hmac, ...rest } = request
|
|
57
57
|
// So we need to include all fields except timestamp and hmac
|
|
58
|
+
// Strip any Buffer values from params before JSON.stringify,
|
|
59
|
+
// since they serialize inconsistently across environments. Both sides must do this.
|
|
60
|
+
let params = message.params;
|
|
61
|
+
if (params && typeof params === 'object') {
|
|
62
|
+
const cleanParams: any = {};
|
|
63
|
+
for (const [k, v] of Object.entries(params)) {
|
|
64
|
+
if (!Buffer.isBuffer(v)) {
|
|
65
|
+
cleanParams[k] = v;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
params = cleanParams;
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const payload: any = {
|
|
59
72
|
jsonrpc: message.jsonrpc,
|
|
60
73
|
method: message.method,
|
|
61
|
-
params
|
|
74
|
+
params,
|
|
62
75
|
id: message.id,
|
|
63
76
|
nonce: message.nonce
|
|
64
77
|
};
|
package/src/proxy/ProxyClient.ts
CHANGED
|
@@ -704,11 +704,17 @@ export class ProxyClient {
|
|
|
704
704
|
// Route to appropriate service with socket wrapper
|
|
705
705
|
const result = await RPCRouter.route(message as JSONRPCRequest, connection.socketWrapper as any);
|
|
706
706
|
|
|
707
|
+
// If result is an async iterable, stream it as binary chunks (__binaryChunk protocol)
|
|
708
|
+
if (result != null && typeof result[Symbol.asyncIterator] === 'function') {
|
|
709
|
+
await this.sendBinaryChunked(connectionId, message.id ?? null, result);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
707
713
|
// Send response
|
|
708
714
|
const response: JSONRPCResponse = {
|
|
709
715
|
jsonrpc: '2.0',
|
|
710
716
|
result,
|
|
711
|
-
id: message.id
|
|
717
|
+
id: message.id ?? null,
|
|
712
718
|
};
|
|
713
719
|
|
|
714
720
|
this.sendToClient(connectionId, 'rpc', response);
|
|
@@ -721,13 +727,59 @@ export class ProxyClient {
|
|
|
721
727
|
ErrorCode.INTERNAL_ERROR,
|
|
722
728
|
error.message || 'Internal error'
|
|
723
729
|
),
|
|
724
|
-
id: message.id
|
|
730
|
+
id: message.id ?? null,
|
|
725
731
|
};
|
|
726
732
|
|
|
727
733
|
this.sendToClient(connectionId, 'rpc', response);
|
|
728
734
|
}
|
|
729
735
|
}
|
|
730
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Stream an async iterable of Buffers to the client using the __binaryChunk protocol.
|
|
739
|
+
* Each chunk is sent as a Socket.IO binary attachment (no base64).
|
|
740
|
+
* The client assembles chunks and resolves the pending RPC request.
|
|
741
|
+
*/
|
|
742
|
+
private async sendBinaryChunked(
|
|
743
|
+
connectionId: string,
|
|
744
|
+
requestId: number | string | null,
|
|
745
|
+
iterable: any
|
|
746
|
+
): Promise<void> {
|
|
747
|
+
if (!this.socket) return;
|
|
748
|
+
const chunkId = crypto.randomBytes(8).toString('hex');
|
|
749
|
+
const total: number = iterable.totalChunks;
|
|
750
|
+
const sizeMB = iterable.size ? `~${Math.round(iterable.size / 1024 / 1024 * 10) / 10}MB` : '?';
|
|
751
|
+
console.log(`📦 Binary streaming: ${total} chunks (${sizeMB}) for request ${requestId}`);
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
let index = 0;
|
|
755
|
+
for await (const chunk of iterable) {
|
|
756
|
+
// Emit directly (bypass sendToClient/needsChunking) — Socket.IO handles Buffer natively
|
|
757
|
+
this.socket.emit('rpc', {
|
|
758
|
+
connectionId,
|
|
759
|
+
data: {
|
|
760
|
+
__binaryChunk: true,
|
|
761
|
+
chunkId,
|
|
762
|
+
requestId,
|
|
763
|
+
index,
|
|
764
|
+
total,
|
|
765
|
+
data: chunk, // Buffer — Socket.IO sends as binary attachment
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
index++;
|
|
769
|
+
}
|
|
770
|
+
} catch (error: any) {
|
|
771
|
+
// Stream error — send a normal RPC error so the client rejects the pending request
|
|
772
|
+
this.socket.emit('rpc', {
|
|
773
|
+
connectionId,
|
|
774
|
+
data: {
|
|
775
|
+
jsonrpc: '2.0',
|
|
776
|
+
id: requestId,
|
|
777
|
+
error: createRPCError(ErrorCode.INTERNAL_ERROR, error.message || 'Binary stream error'),
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
731
783
|
/**
|
|
732
784
|
* Send message to client via proxy
|
|
733
785
|
* Automatically chunks large payloads (>800kB)
|
|
@@ -56,6 +56,22 @@ export class FilesystemService {
|
|
|
56
56
|
result = await this.readFile(safePath!, params);
|
|
57
57
|
logFsRead(method, params, deviceId, true, undefined, { size: result.size, encoding: result.encoding });
|
|
58
58
|
return result;
|
|
59
|
+
case 'stat':
|
|
60
|
+
result = await this.stat(safePath!);
|
|
61
|
+
logFsRead(method, params, deviceId, true, undefined, { size: result.size });
|
|
62
|
+
return result;
|
|
63
|
+
case 'readFileBinary':
|
|
64
|
+
result = await this.readFileBinary(safePath!, params);
|
|
65
|
+
logFsRead(method, params, deviceId, true, undefined,
|
|
66
|
+
result.rangeLength !== undefined
|
|
67
|
+
? { size: result.size, offset: result.rangeOffset, length: result.rangeLength }
|
|
68
|
+
: { size: result.size, totalChunks: result.totalChunks }
|
|
69
|
+
);
|
|
70
|
+
return result;
|
|
71
|
+
case 'writeBinary':
|
|
72
|
+
result = await this.writeBinary(safePath!, params);
|
|
73
|
+
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
74
|
+
return result;
|
|
59
75
|
case 'write':
|
|
60
76
|
result = await this.write(safePath!, params);
|
|
61
77
|
logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
|
|
@@ -114,7 +130,7 @@ export class FilesystemService {
|
|
|
114
130
|
} catch (err) {
|
|
115
131
|
error = err;
|
|
116
132
|
// Determine if this was a read or write operation for logging
|
|
117
|
-
const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat'];
|
|
133
|
+
const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat', 'stat'];
|
|
118
134
|
if (readOps.includes(method)) {
|
|
119
135
|
logFsRead(method, params, deviceId, false, error);
|
|
120
136
|
} else {
|
|
@@ -129,8 +145,11 @@ export class FilesystemService {
|
|
|
129
145
|
* Prevents directory traversal and symlink escape attacks
|
|
130
146
|
*/
|
|
131
147
|
private async validatePath(userPath: string): Promise<string> {
|
|
148
|
+
// Strip query string and fragment (cache-busters sent by the browser)
|
|
149
|
+
const cleanPath = userPath.split('?')[0].split('#')[0];
|
|
150
|
+
|
|
132
151
|
// Normalize path
|
|
133
|
-
const normalized = path.normalize(
|
|
152
|
+
const normalized = path.normalize(cleanPath);
|
|
134
153
|
|
|
135
154
|
// Prevent directory traversal
|
|
136
155
|
if (normalized.includes('..')) {
|
|
@@ -251,11 +270,132 @@ export class FilesystemService {
|
|
|
251
270
|
/**
|
|
252
271
|
* Read file contents
|
|
253
272
|
*/
|
|
273
|
+
private async stat(safePath: string): Promise<any> {
|
|
274
|
+
try {
|
|
275
|
+
const stats = await fs.stat(safePath);
|
|
276
|
+
return {
|
|
277
|
+
size: stats.size,
|
|
278
|
+
mtime: stats.mtimeMs,
|
|
279
|
+
isFile: stats.isFile(),
|
|
280
|
+
isDirectory: stats.isDirectory(),
|
|
281
|
+
};
|
|
282
|
+
} catch (error: any) {
|
|
283
|
+
if (error.code === 'ENOENT') {
|
|
284
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Binary read — returns an async iterable of Buffer chunks.
|
|
292
|
+
* ProxyClient detects the iterable and streams chunks to the client via __binaryChunk protocol.
|
|
293
|
+
* File handle is opened once and held for the full iteration (atomic w.r.t. the file descriptor).
|
|
294
|
+
*/
|
|
295
|
+
private async readFileBinary(safePath: string, params: any): Promise<any> {
|
|
296
|
+
const CHUNK_SIZE = params.chunkSize || 750 * 1024;
|
|
297
|
+
|
|
298
|
+
let stats: any;
|
|
299
|
+
try {
|
|
300
|
+
stats = await fs.stat(safePath);
|
|
301
|
+
} catch (error: any) {
|
|
302
|
+
if (error.code === 'ENOENT') {
|
|
303
|
+
throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
|
|
304
|
+
}
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const totalSize = stats.size;
|
|
309
|
+
|
|
310
|
+
// Range read: single-chunk iterable for a specific byte range (used by video streaming)
|
|
311
|
+
if (params.offset !== undefined) {
|
|
312
|
+
const offset = params.offset as number;
|
|
313
|
+
const rangeLength = Math.min(
|
|
314
|
+
params.length !== undefined ? (params.length as number) : (totalSize - offset),
|
|
315
|
+
totalSize - offset
|
|
316
|
+
);
|
|
317
|
+
if (offset < 0 || rangeLength < 0) {
|
|
318
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
|
|
319
|
+
}
|
|
320
|
+
const iterable = {
|
|
321
|
+
totalChunks: 1,
|
|
322
|
+
size: totalSize,
|
|
323
|
+
rangeOffset: offset,
|
|
324
|
+
rangeLength,
|
|
325
|
+
async *[Symbol.asyncIterator]() {
|
|
326
|
+
const fh = await fs.open(safePath, 'r');
|
|
327
|
+
try {
|
|
328
|
+
const buf = Buffer.alloc(rangeLength);
|
|
329
|
+
if (rangeLength > 0) await fh.read(buf, 0, rangeLength, offset);
|
|
330
|
+
yield buf;
|
|
331
|
+
} finally {
|
|
332
|
+
await fh.close();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
return iterable;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const totalChunks = Math.max(1, Math.ceil(totalSize / CHUNK_SIZE));
|
|
340
|
+
|
|
341
|
+
// Return an async iterable. ProxyClient will detect [Symbol.asyncIterator] and stream chunks.
|
|
342
|
+
const iterable = {
|
|
343
|
+
totalChunks,
|
|
344
|
+
size: totalSize,
|
|
345
|
+
async *[Symbol.asyncIterator]() {
|
|
346
|
+
const fh = await fs.open(safePath, 'r');
|
|
347
|
+
try {
|
|
348
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
349
|
+
const offset = i * CHUNK_SIZE;
|
|
350
|
+
const length = Math.min(CHUNK_SIZE, totalSize - offset);
|
|
351
|
+
const buf = Buffer.alloc(length);
|
|
352
|
+
if (length > 0) await fh.read(buf, 0, length, offset);
|
|
353
|
+
yield buf;
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
await fh.close();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
return iterable;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Binary write — receives the full buffer as a Socket.IO binary attachment.
|
|
365
|
+
* No accumulation needed; ProxyClient routes directly once Socket.IO reassembles the binary.
|
|
366
|
+
*/
|
|
367
|
+
private async writeBinary(safePath: string, params: any): Promise<any> {
|
|
368
|
+
const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.alloc(0);
|
|
369
|
+
return this.write(safePath, { ...params, contents: buffer, encoding: 'binary' });
|
|
370
|
+
}
|
|
371
|
+
|
|
254
372
|
private async readFile(safePath: string, params: any): Promise<any> {
|
|
255
373
|
try {
|
|
256
374
|
const stats = await fs.stat(safePath);
|
|
257
375
|
|
|
258
|
-
|
|
376
|
+
const encoding = params.encoding || 'utf8';
|
|
377
|
+
|
|
378
|
+
// Range read: bypass maxFileSize limit since we only read a small chunk
|
|
379
|
+
if (params.offset !== undefined) {
|
|
380
|
+
const offset = params.offset as number;
|
|
381
|
+
const length = Math.min(
|
|
382
|
+
params.length !== undefined ? (params.length as number) : (stats.size - offset),
|
|
383
|
+
stats.size - offset
|
|
384
|
+
);
|
|
385
|
+
if (offset < 0 || length <= 0) {
|
|
386
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
|
|
387
|
+
}
|
|
388
|
+
const fh = await fs.open(safePath, 'r');
|
|
389
|
+
try {
|
|
390
|
+
const buf = Buffer.alloc(length);
|
|
391
|
+
await fh.read(buf, 0, length, offset);
|
|
392
|
+
return { buffer: buf, size: stats.size, offset, length, encoding: 'binary' };
|
|
393
|
+
} finally {
|
|
394
|
+
await fh.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check file size limit for full reads
|
|
259
399
|
const maxSize = parseFileSize(this.config.maxFileSize);
|
|
260
400
|
if (stats.size > maxSize) {
|
|
261
401
|
throw createRPCError(
|
|
@@ -265,8 +405,6 @@ export class FilesystemService {
|
|
|
265
405
|
);
|
|
266
406
|
}
|
|
267
407
|
|
|
268
|
-
const encoding = params.encoding || 'utf8';
|
|
269
|
-
|
|
270
408
|
if (encoding === 'binary') {
|
|
271
409
|
// Binary file - return buffer directly in response
|
|
272
410
|
const buffer = await fs.readFile(safePath);
|
|
@@ -325,25 +463,9 @@ export class FilesystemService {
|
|
|
325
463
|
|
|
326
464
|
// Write file (atomic or regular)
|
|
327
465
|
if (atomic) {
|
|
328
|
-
|
|
329
|
-
if (encoding === 'binary') {
|
|
330
|
-
const buffer = typeof params.contents === 'string'
|
|
331
|
-
? Buffer.from(params.contents, 'base64')
|
|
332
|
-
: Buffer.from(params.contents || Buffer.alloc(0));
|
|
333
|
-
await writeFileAtomic(safePath, buffer);
|
|
334
|
-
} else {
|
|
335
|
-
await writeFileAtomic(safePath, params.contents, { encoding });
|
|
336
|
-
}
|
|
466
|
+
await writeFileAtomic(safePath, params.contents, { encoding });
|
|
337
467
|
} else {
|
|
338
|
-
|
|
339
|
-
if (encoding === 'binary') {
|
|
340
|
-
const buffer = typeof params.contents === 'string'
|
|
341
|
-
? Buffer.from(params.contents, 'base64')
|
|
342
|
-
: Buffer.from(params.contents || Buffer.alloc(0));
|
|
343
|
-
await fs.writeFile(safePath, buffer);
|
|
344
|
-
} else {
|
|
345
|
-
await fs.writeFile(safePath, params.contents, encoding);
|
|
346
|
-
}
|
|
468
|
+
await fs.writeFile(safePath, params.contents, encoding);
|
|
347
469
|
}
|
|
348
470
|
|
|
349
471
|
// Set executable if requested
|
|
@@ -462,6 +584,9 @@ export class FilesystemService {
|
|
|
462
584
|
*/
|
|
463
585
|
private async getFileHash(safePath: string): Promise<any> {
|
|
464
586
|
const hash = await this.getFileHashValue(safePath);
|
|
587
|
+
if (hash === null) {
|
|
588
|
+
return { hash: null, size: 0, mtime: 0 };
|
|
589
|
+
}
|
|
465
590
|
const stats = await fs.stat(safePath);
|
|
466
591
|
|
|
467
592
|
return {
|