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.
@@ -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: message.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 || null,
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 || null,
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(userPath);
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
- // Check file size limit
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
- // 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
- }
410
+ await writeFileAtomic(safePath, params.contents, { encoding });
293
411
  }
294
412
  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
- }
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 from base64-encoded string', async () => {
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 base64 = bytes.toString('base64');
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 from base64-encoded string', async () => {
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 base64 = bytes.toString('base64');
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 base64', async () => {
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
- const base64 = bytes.toString('base64');
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spck",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI tool for Spck Editor - provides remote filesystem, git, browser proxing, and terminal access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",