@nightowne/tas-cli 2.0.0 → 2.3.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/src/fuse/mount.js CHANGED
@@ -5,21 +5,38 @@
5
5
  * This is the killer feature - use Telegram like a regular folder!
6
6
  */
7
7
 
8
- import Fuse from 'fuse-native';
9
8
  import path from 'path';
10
9
  import fs from 'fs';
10
+ import crypto from 'crypto';
11
+ import { processFile } from '../index.js';
12
+
13
+ let Fuse;
14
+ try {
15
+ Fuse = (await import('fuse-native')).default;
16
+ } catch {
17
+ // fuse-native is optional — unavailable on ARM64 or systems without libfuse
18
+ }
11
19
  import { TelegramClient } from '../telegram/client.js';
12
20
  import { Encryptor } from '../crypto/encryption.js';
13
21
  import { Compressor } from '../utils/compression.js';
14
22
  import { FileIndex } from '../db/index.js';
15
23
  import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
16
24
 
17
- // File cache for performance (avoid re-downloading)
18
- const fileCache = new Map();
19
25
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
26
+ const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
20
27
 
21
28
  export class TelegramFS {
22
29
  constructor(options) {
30
+ if (!Fuse) {
31
+ throw new Error(
32
+ 'fuse-native is not available on this system.\n' +
33
+ ' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
34
+ ' On macOS: brew install macfuse && npm install fuse-native\n' +
35
+ ' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
36
+ ' All other TAS commands (push, pull, sync, share) work without FUSE.'
37
+ );
38
+ }
39
+
23
40
  this.dataDir = options.dataDir;
24
41
  this.password = options.password;
25
42
  this.config = options.config;
@@ -35,6 +52,9 @@ export class TelegramFS {
35
52
 
36
53
  // Pending writes buffer
37
54
  this.writeBuffers = new Map();
55
+
56
+ // Instance-level file cache to prevent cross-talk
57
+ this.fileCache = new Map();
38
58
  }
39
59
 
40
60
  async initialize() {
@@ -70,7 +90,7 @@ export class TelegramFS {
70
90
  mtime: new Date(),
71
91
  atime: new Date(),
72
92
  ctime: new Date(),
73
- size: wb.data.length,
93
+ size: wb.size,
74
94
  mode: 0o100644, // regular file
75
95
  uid: process.getuid?.() || 0,
76
96
  gid: process.getgid?.() || 0
@@ -104,9 +124,14 @@ export class TelegramFS {
104
124
  }
105
125
 
106
126
  const files = this.db.listAll();
107
- const names = files.map(f => f.filename);
127
+ const nameSet = new Set(files.map(f => f.filename));
128
+
129
+ // Include files currently being written (not yet released/uploaded)
130
+ for (const name of this.writeBuffers.keys()) {
131
+ nameSet.add(name);
132
+ }
108
133
 
109
- return cb(0, names);
134
+ return cb(0, [...nameSet]);
110
135
  }
111
136
 
112
137
  /**
@@ -130,26 +155,34 @@ export class TelegramFS {
130
155
  }
131
156
 
132
157
  /**
133
- * Read file contents
158
+ * Read file contents from disk cache
134
159
  */
135
160
  async read(filepath, fd, buffer, length, position, cb) {
136
161
  const filename = path.basename(filepath);
137
162
 
138
163
  try {
164
+ // Check write buffers first
165
+ const wb = this.writeBuffers.get(filename);
166
+ if (wb) {
167
+ const bytesRead = fs.readSync(wb.fd, buffer, 0, length, position);
168
+ return cb(bytesRead);
169
+ }
170
+
139
171
  // Check cache first
140
- let data = this.getCached(filename);
172
+ let cachedPath = this.getCached(filename);
141
173
 
142
- if (!data) {
143
- // Download and decrypt
144
- data = await this.downloadFile(filename);
145
- this.setCache(filename, data);
174
+ if (!cachedPath) {
175
+ // Download, decrypt, and save to disk cache
176
+ cachedPath = await this.downloadFileToCache(filename);
177
+ this.setCache(filename, cachedPath);
146
178
  }
147
179
 
148
- // Copy requested portion to buffer
149
- const slice = data.subarray(position, position + length);
150
- slice.copy(buffer);
180
+ // Copy requested portion to buffer from disk
181
+ const fdDisk = fs.openSync(cachedPath, 'r');
182
+ const bytesRead = fs.readSync(fdDisk, buffer, 0, length, position);
183
+ fs.closeSync(fdDisk);
151
184
 
152
- return cb(slice.length);
185
+ return cb(bytesRead);
153
186
  } catch (err) {
154
187
  console.error('Read error:', err.message);
155
188
  return cb(Fuse.EIO);
@@ -157,34 +190,55 @@ export class TelegramFS {
157
190
  }
158
191
 
159
192
  /**
160
- * Write to a file (buffers until release)
193
+ * Helper to get or create a disk-buffered write structure
161
194
  */
162
- write(filepath, fd, buffer, length, position, cb) {
163
- const filename = path.basename(filepath);
195
+ getOrCreateWriteBuffer(filename) {
196
+ if (this.writeBuffers.has(filename)) {
197
+ return this.writeBuffers.get(filename);
198
+ }
164
199
 
165
- // Get or create write buffer
166
- if (!this.writeBuffers.has(filename)) {
167
- this.writeBuffers.set(filename, {
168
- data: Buffer.alloc(0),
169
- modified: true
170
- });
200
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
201
+ if (!fs.existsSync(fuseTmpDir)) {
202
+ fs.mkdirSync(fuseTmpDir, { recursive: true });
171
203
  }
172
204
 
173
- const wb = this.writeBuffers.get(filename);
205
+ const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
206
+ const fdDisk = fs.openSync(tmpPath, 'w+');
174
207
 
175
- // Expand buffer if needed
176
- const newSize = Math.max(wb.data.length, position + length);
177
- if (newSize > wb.data.length) {
178
- const newBuf = Buffer.alloc(newSize);
179
- wb.data.copy(newBuf);
180
- wb.data = newBuf;
208
+ // Check if there is an existing file cached
209
+ const cachedPath = this.getCached(filename);
210
+ let size = 0;
211
+ if (cachedPath && fs.existsSync(cachedPath)) {
212
+ const content = fs.readFileSync(cachedPath);
213
+ fs.writeSync(fdDisk, content, 0, content.length, 0);
214
+ size = content.length;
181
215
  }
182
216
 
183
- // Copy incoming data
184
- buffer.copy(wb.data, position, 0, length);
185
- wb.modified = true;
217
+ const wb = {
218
+ tmpPath,
219
+ fd: fdDisk,
220
+ size,
221
+ modified: true
222
+ };
223
+ this.writeBuffers.set(filename, wb);
224
+ return wb;
225
+ }
186
226
 
187
- return cb(length);
227
+ /**
228
+ * Write to a file (buffers until release)
229
+ */
230
+ write(filepath, fd, buffer, length, position, cb) {
231
+ const filename = path.basename(filepath);
232
+ try {
233
+ const wb = this.getOrCreateWriteBuffer(filename);
234
+ fs.writeSync(wb.fd, buffer, 0, length, position);
235
+ wb.size = Math.max(wb.size, position + length);
236
+ wb.modified = true;
237
+ return cb(length);
238
+ } catch (err) {
239
+ console.error('[FUSE] Write error:', err.message);
240
+ return cb(Fuse.EIO);
241
+ }
188
242
  }
189
243
 
190
244
  /**
@@ -195,9 +249,19 @@ export class TelegramFS {
195
249
 
196
250
  console.log(`[FUSE] Creating file: ${filename}`);
197
251
 
198
- // Initialize empty write buffer
252
+ // Initialize empty disk-buffered write
253
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
254
+ if (!fs.existsSync(fuseTmpDir)) {
255
+ fs.mkdirSync(fuseTmpDir, { recursive: true });
256
+ }
257
+
258
+ const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
259
+ const fdDisk = fs.openSync(tmpPath, 'w+');
260
+
199
261
  this.writeBuffers.set(filename, {
200
- data: Buffer.alloc(0),
262
+ tmpPath,
263
+ fd: fdDisk,
264
+ size: 0,
201
265
  modified: true,
202
266
  isNew: true
203
267
  });
@@ -219,15 +283,26 @@ export class TelegramFS {
219
283
  const filename = path.basename(filepath);
220
284
 
221
285
  const wb = this.writeBuffers.get(filename);
222
- if (!wb || !wb.modified) {
286
+ if (!wb) {
287
+ return cb(0);
288
+ }
289
+
290
+ if (!wb.modified) {
291
+ try { fs.closeSync(wb.fd); } catch (e) {}
292
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
293
+ this.writeBuffers.delete(filename);
223
294
  return cb(0);
224
295
  }
225
296
 
226
297
  try {
227
- // Upload to Telegram
228
- await this.uploadFile(filename, wb.data);
298
+ // Close fd so we can read and upload cleanly
299
+ try { fs.closeSync(wb.fd); } catch (e) {}
300
+
301
+ // Upload to Telegram from the temp path
302
+ await this.uploadFile(filename, wb.tmpPath);
229
303
 
230
- // Clear write buffer
304
+ // Clean up temp file
305
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
231
306
  this.writeBuffers.delete(filename);
232
307
 
233
308
  // Invalidate cache
@@ -235,7 +310,9 @@ export class TelegramFS {
235
310
 
236
311
  return cb(0);
237
312
  } catch (err) {
238
- console.error('Release error:', err.message);
313
+ console.error('[FUSE] Release error:', err.message);
314
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
315
+ this.writeBuffers.delete(filename);
239
316
  return cb(Fuse.EIO);
240
317
  }
241
318
  }
@@ -252,7 +329,7 @@ export class TelegramFS {
252
329
  }
253
330
 
254
331
  try {
255
- // Delete from Telegram (optional - could just remove from index)
332
+ // Delete from Telegram
256
333
  const chunks = this.db.getChunks(file.id);
257
334
  for (const chunk of chunks) {
258
335
  await this.client.deleteMessage(chunk.message_id);
@@ -266,7 +343,7 @@ export class TelegramFS {
266
343
 
267
344
  return cb(0);
268
345
  } catch (err) {
269
- console.error('Unlink error:', err.message);
346
+ console.error('[FUSE] Unlink error:', err.message);
270
347
  return cb(Fuse.EIO);
271
348
  }
272
349
  }
@@ -288,10 +365,10 @@ export class TelegramFS {
288
365
  .run(newName, file.id);
289
366
 
290
367
  // Update cache key
291
- const cached = fileCache.get(oldName);
368
+ const cached = this.fileCache.get(oldName);
292
369
  if (cached) {
293
- fileCache.delete(oldName);
294
- fileCache.set(newName, cached);
370
+ this.fileCache.delete(oldName);
371
+ this.fileCache.set(newName, cached);
295
372
  }
296
373
 
297
374
  return cb(0);
@@ -302,77 +379,98 @@ export class TelegramFS {
302
379
  */
303
380
  truncate(filepath, size, cb) {
304
381
  const filename = path.basename(filepath);
305
-
306
- // Get or load into write buffer
307
- if (!this.writeBuffers.has(filename)) {
308
- const cached = this.getCached(filename);
309
- if (cached) {
310
- this.writeBuffers.set(filename, {
311
- data: Buffer.from(cached),
312
- modified: true
313
- });
314
- } else {
315
- this.writeBuffers.set(filename, {
316
- data: Buffer.alloc(0),
317
- modified: true
318
- });
319
- }
320
- }
321
-
322
- const wb = this.writeBuffers.get(filename);
323
-
324
- if (size < wb.data.length) {
325
- wb.data = wb.data.subarray(0, size);
326
- } else if (size > wb.data.length) {
327
- const newBuf = Buffer.alloc(size);
328
- wb.data.copy(newBuf);
329
- wb.data = newBuf;
382
+ try {
383
+ const wb = this.getOrCreateWriteBuffer(filename);
384
+ fs.ftruncateSync(wb.fd, size);
385
+ wb.size = size;
386
+ wb.modified = true;
387
+ return cb(0);
388
+ } catch (err) {
389
+ console.error('[FUSE] Truncate error:', err.message);
390
+ return cb(Fuse.EIO);
330
391
  }
331
-
332
- wb.modified = true;
333
- return cb(0);
334
392
  }
335
393
 
336
394
  // ============== Helper Methods ==============
337
395
 
338
- async downloadFile(filename) {
396
+ async downloadFileToCache(filename) {
339
397
  const file = this.db.findByName(filename);
340
398
  if (!file) throw new Error('File not found');
341
399
 
400
+ const cacheDir = path.join(this.dataDir, 'cache');
401
+ if (!fs.existsSync(cacheDir)) {
402
+ fs.mkdirSync(cacheDir, { recursive: true });
403
+ }
404
+
405
+ const outputPath = path.join(cacheDir, file.hash);
406
+
407
+ // If it's already fully downloaded and cached on disk, return path
408
+ if (fs.existsSync(outputPath)) {
409
+ const stats = fs.statSync(outputPath);
410
+ if (stats.size === file.original_size) {
411
+ return outputPath;
412
+ }
413
+ }
414
+
342
415
  const chunks = this.db.getChunks(file.id);
343
416
  if (chunks.length === 0) throw new Error('No chunks found');
344
417
 
345
- // Download all chunks
346
- const downloadedChunks = [];
347
-
348
- for (const chunk of chunks) {
349
- const data = await this.client.downloadFile(chunk.file_telegram_id);
350
- const header = parseHeader(data);
351
- const payload = data.subarray(HEADER_SIZE);
418
+ // Pre-sort chunks
419
+ chunks.sort((a, b) => a.chunk_index - b.chunk_index);
420
+
421
+ const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
422
+ const header = parseHeader(firstChunkData);
423
+ let wasCompressed = header.compressed;
424
+
425
+ const decryptStream = this.encryptor.getDecryptStream();
426
+ const decompressStream = this.compressor.getDecompressStream(wasCompressed);
427
+
428
+ const { Readable } = await import('stream');
429
+ const { pipeline } = await import('stream/promises');
430
+
431
+ const self = this;
432
+ let currentChunkIndex = 0;
433
+ let preloadedFirstChunk = firstChunkData;
434
+
435
+ const downloadStream = new Readable({
436
+ async read() {
437
+ try {
438
+ if (currentChunkIndex >= chunks.length) {
439
+ this.push(null);
440
+ return;
441
+ }
442
+
443
+ const chunk = chunks[currentChunkIndex];
444
+ let data;
445
+ if (currentChunkIndex === 0 && preloadedFirstChunk) {
446
+ data = preloadedFirstChunk;
447
+ preloadedFirstChunk = null;
448
+ } else {
449
+ data = await self.client.downloadFile(chunk.file_telegram_id);
450
+ }
451
+
452
+ const payload = data.subarray(HEADER_SIZE);
453
+ this.push(payload);
454
+ currentChunkIndex++;
455
+ } catch (err) {
456
+ this.destroy(err);
457
+ }
458
+ }
459
+ });
352
460
 
353
- downloadedChunks.push({
354
- index: header.chunkIndex,
355
- data: payload,
356
- compressed: header.compressed
357
- });
358
- }
461
+ const tmpOutputPath = outputPath + '.tmp';
462
+ const writeStream = fs.createWriteStream(tmpOutputPath);
359
463
 
360
- // Reassemble
361
- downloadedChunks.sort((a, b) => a.index - b.index);
362
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
464
+ // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
465
+ await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
363
466
 
364
- // Decrypt
365
- const compressedData = this.encryptor.decrypt(encryptedData);
467
+ // Rename to final atomic path
468
+ fs.renameSync(tmpOutputPath, outputPath);
366
469
 
367
- // Decompress
368
- const wasCompressed = downloadedChunks[0].compressed;
369
- return await this.compressor.decompress(compressedData, wasCompressed);
470
+ return outputPath;
370
471
  }
371
472
 
372
- async uploadFile(filename, data) {
373
- const { hashData } = await import('../crypto/encryption.js');
374
- const hash = hashData(data);
375
-
473
+ async uploadFile(filename, tempPath) {
376
474
  // Check if already exists by name
377
475
  const existingByName = this.db.findByName(filename);
378
476
  if (existingByName) {
@@ -384,76 +482,65 @@ export class TelegramFS {
384
482
  this.db.delete(existingByName.id);
385
483
  }
386
484
 
387
- // Check if already exists by hash (same content, different name)
388
- const existingByHash = this.db.findByHash(hash);
389
- if (existingByHash) {
390
- // Same content already exists, just skip
391
- console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
392
- return;
393
- }
394
-
395
- // Compress
396
- const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
397
-
398
- // Encrypt
399
- const encryptedData = this.encryptor.encrypt(compressedData);
400
-
401
- // Create temp file with header
402
- const tempDir = path.join(this.dataDir, 'tmp');
403
- if (!fs.existsSync(tempDir)) {
404
- fs.mkdirSync(tempDir, { recursive: true });
405
- }
406
-
407
- const flags = compressed ? 1 : 0;
408
- const header = createHeader(filename, data.length, 0, 1, flags);
409
- const fileData = Buffer.concat([header, encryptedData]);
410
-
411
- const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
412
- fs.writeFileSync(tempPath, fileData);
413
-
414
- // Upload to Telegram
415
- const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
416
-
417
- // Add to index
418
- const fileId = this.db.addFile({
419
- filename,
420
- hash,
421
- originalSize: data.length,
422
- storedSize: encryptedData.length,
423
- chunks: 1,
424
- compressed
485
+ // Run the standard chunked upload pipeline (this handles chunking, encryption, compression, and DB entry)
486
+ await processFile(tempPath, {
487
+ password: this.password,
488
+ dataDir: this.dataDir,
489
+ customName: filename,
490
+ config: this.config
425
491
  });
426
-
427
- this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
428
- this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
429
- .run(result.fileId, fileId, 0);
430
-
431
- // Cleanup
432
- fs.unlinkSync(tempPath);
433
- try { fs.rmdirSync(tempDir); } catch (e) { }
434
492
  }
435
493
 
436
494
  getCached(filename) {
437
- const entry = fileCache.get(filename);
495
+ const entry = this.fileCache.get(filename);
438
496
  if (!entry) return null;
439
497
 
440
498
  if (Date.now() - entry.timestamp > CACHE_TTL) {
441
- fileCache.delete(filename);
499
+ // Expired, delete the file if possible
500
+ try {
501
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
502
+ } catch (e) { }
503
+ this.fileCache.delete(filename);
442
504
  return null;
443
505
  }
444
506
 
445
- return entry.data;
507
+ // Extend cache TTL on read
508
+ entry.timestamp = Date.now();
509
+ return entry.path;
446
510
  }
447
511
 
448
- setCache(filename, data) {
449
- fileCache.set(filename, {
450
- data,
512
+ setCache(filename, cachePath) {
513
+ // Evict oldest entry if cache is full
514
+ if (this.fileCache.size >= CACHE_MAX_ENTRIES) {
515
+ let oldestKey = null;
516
+ let oldestTime = Infinity;
517
+ for (const [key, entry] of this.fileCache) {
518
+ if (entry.timestamp < oldestTime) {
519
+ oldestTime = entry.timestamp;
520
+ oldestKey = key;
521
+ }
522
+ }
523
+ if (oldestKey) {
524
+ const evicted = this.fileCache.get(oldestKey);
525
+ try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
526
+ this.fileCache.delete(oldestKey);
527
+ }
528
+ }
529
+
530
+ this.fileCache.set(filename, {
531
+ path: cachePath,
451
532
  timestamp: Date.now()
452
533
  });
453
534
  }
454
535
 
455
536
  invalidateCache(filename) {
456
- fileCache.delete(filename);
537
+ const entry = this.fileCache.get(filename);
538
+ if (entry) {
539
+ try {
540
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
541
+ } catch (e) { }
542
+ this.fileCache.delete(filename);
543
+ }
457
544
  }
458
545
 
459
546
  /**
@@ -465,6 +552,17 @@ export class TelegramFS {
465
552
  fs.mkdirSync(this.mountPoint, { recursive: true });
466
553
  }
467
554
 
555
+ // Clean up any stray temp files from previous sessions
556
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
557
+ if (fs.existsSync(fuseTmpDir)) {
558
+ try {
559
+ const files = fs.readdirSync(fuseTmpDir);
560
+ for (const file of files) {
561
+ fs.unlinkSync(path.join(fuseTmpDir, file));
562
+ }
563
+ } catch (e) {}
564
+ }
565
+
468
566
  const ops = {
469
567
  getattr: this.getattr.bind(this),
470
568
  readdir: this.readdir.bind(this),