@nightowne/tas-cli 2.4.0 → 3.0.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
@@ -7,6 +7,7 @@
7
7
 
8
8
  import path from 'path';
9
9
  import fs from 'fs';
10
+ import os from 'os';
10
11
  import { pipeline } from 'stream/promises';
11
12
 
12
13
  let Fuse;
@@ -15,26 +16,93 @@ try {
15
16
  } catch {
16
17
  // fuse-native is optional — unavailable on ARM64 or systems without libfuse
17
18
  }
18
- import { TelegramClient } from '../telegram/client.js';
19
+ import { TelegramPool } from '../telegram/pool.js';
19
20
  import { Encryptor } from '../crypto/encryption.js';
20
21
  import { Compressor } from '../utils/compression.js';
21
22
  import { FileIndex } from '../db/index.js';
22
- import { createHeader } from '../utils/chunker.js';
23
23
  import { createDownloadPipeline } from '../utils/download-stream.js';
24
+ import { processFile } from '../index.js';
25
+ import { hashFile } from '../crypto/encryption.js';
26
+ import { backupRemoteManifest } from '../manifest.js';
27
+ import {
28
+ normalizeLogicalPath,
29
+ listLogicalChildren,
30
+ isImplicitDirectory,
31
+ parentLogicalPath
32
+ } from '../utils/logical-path.js';
24
33
 
25
34
  // File cache for performance (avoid re-downloading)
26
35
  const fileCache = new Map();
27
36
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
28
37
  const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
29
38
 
39
+ function withTimeout(promise, ms, label) {
40
+ let timer;
41
+ const timeout = new Promise((_, reject) => {
42
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
43
+ });
44
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
45
+ }
46
+
47
+ /** Verify the native FUSE stack, not merely that the JS module imports. */
48
+ export async function checkFuseRuntime() {
49
+ if (process.platform === 'darwin') {
50
+ return {
51
+ supported: false,
52
+ reason: 'macOS mount is unsupported: fuse-native 2.x targets obsolete OSXFUSE APIs and current macFUSE/Apple Silicon is not validated'
53
+ };
54
+ }
55
+ if (!Fuse) return { supported: false, reason: 'fuse-native is not installed or failed to load' };
56
+
57
+ const configured = await new Promise((resolve, reject) => {
58
+ Fuse.isConfigured((error, ready) => error ? reject(error) : resolve(ready));
59
+ });
60
+ if (!configured) return { supported: false, reason: 'FUSE kernel/userspace support is not configured' };
61
+
62
+ const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'tas-fuse-doctor-'));
63
+ const now = new Date();
64
+ const probe = new Fuse(mountPoint, {
65
+ getattr(filepath, cb) {
66
+ if (filepath === '/') return cb(0, { mode: 0o40755, size: 4096, mtime: now, atime: now, ctime: now });
67
+ if (filepath === '/probe') return cb(0, { mode: 0o100444, size: 0, mtime: now, atime: now, ctime: now });
68
+ return cb(Fuse.ENOENT);
69
+ },
70
+ readdir(filepath, cb) {
71
+ return filepath === '/' ? cb(0, ['probe']) : cb(Fuse.ENOENT);
72
+ },
73
+ open(filepath, flags, cb) { return cb(0, 1); },
74
+ read(filepath, fd, buffer, length, position, cb) { return cb(0); }
75
+ }, { force: true, mkdir: true });
76
+
77
+ let mounted = false;
78
+ try {
79
+ await withTimeout(new Promise((resolve, reject) => probe.mount(error => error ? reject(error) : resolve())), 10000, 'FUSE mount');
80
+ mounted = true;
81
+ const entries = await withTimeout(fs.promises.readdir(mountPoint), 10000, 'FUSE readdir');
82
+ if (!entries.includes('probe')) throw new Error('FUSE readdir smoke test returned unexpected entries');
83
+ await withTimeout(new Promise((resolve, reject) => probe.unmount(error => error ? reject(error) : resolve())), 10000, 'FUSE unmount');
84
+ mounted = false;
85
+ return { supported: true };
86
+ } finally {
87
+ if (mounted) {
88
+ try { await new Promise(resolve => probe.unmount(() => resolve())); } catch { }
89
+ }
90
+ try { fs.rmdirSync(mountPoint); } catch { }
91
+ }
92
+ }
93
+
30
94
  export class TelegramFS {
31
95
  constructor(options) {
96
+ if (process.platform === 'darwin') {
97
+ throw new Error(
98
+ 'TAS mount is currently unsupported on macOS. fuse-native 2.x targets obsolete OSXFUSE APIs ' +
99
+ 'and is not compatible with current macFUSE on Apple Silicon. Use push/pull/sync/share instead.'
100
+ );
101
+ }
32
102
  if (!Fuse) {
33
103
  throw new Error(
34
104
  'fuse-native is not available on this system.\n' +
35
105
  ' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
36
- ' On macOS: brew install macfuse && npm install fuse-native\n' +
37
- ' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
38
106
  ' All other TAS commands (push, pull, sync, share) work without FUSE.'
39
107
  );
40
108
  }
@@ -43,6 +111,7 @@ export class TelegramFS {
43
111
  this.password = options.password;
44
112
  this.config = options.config;
45
113
  this.mountPoint = options.mountPoint;
114
+ this.backupManifest = options.backupManifest || backupRemoteManifest;
46
115
 
47
116
  this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
48
117
  this.db.init();
@@ -52,25 +121,102 @@ export class TelegramFS {
52
121
  this.client = null;
53
122
  this.fuse = null;
54
123
 
55
- // Pending writes buffer
124
+ // Pending writes are disk-backed so a large FUSE write does not grow
125
+ // the Node process by the full file size.
56
126
  this.writeBuffers = new Map();
127
+ this.virtualDirs = new Set(['']);
128
+ this.filePaths = new Set();
129
+ this.fileByLogicalPath = new Map();
130
+ this.implicitDirs = new Set(['']);
131
+ this.childrenByDir = new Map();
132
+ this._refreshPathIndex();
57
133
  }
58
134
 
59
135
  async initialize() {
60
136
  // Connect to Telegram
61
- this.client = new TelegramClient(this.dataDir);
62
- await this.client.initialize(this.config.botToken);
63
- this.client.setChatId(this.config.chatId);
137
+ this.client = new TelegramPool(this.dataDir, this.config.bots);
138
+ }
139
+
140
+ _logical(filepath, allowRoot = false) {
141
+ return normalizeLogicalPath(filepath, { allowRoot });
142
+ }
143
+
144
+ _allLogicalPaths() {
145
+ return [
146
+ ...this.filePaths,
147
+ ...this.writeBuffers.keys()
148
+ ];
149
+ }
150
+
151
+ _isDirectory(logicalPath) {
152
+ return this.virtualDirs.has(logicalPath) || this.implicitDirs.has(logicalPath) ||
153
+ isImplicitDirectory([...this.writeBuffers.keys()], logicalPath);
154
+ }
155
+
156
+ _refreshPathIndex() {
157
+ this.filePaths = new Set();
158
+ this.fileByLogicalPath = new Map();
159
+ this.implicitDirs = new Set(['']);
160
+ this.childrenByDir = new Map();
161
+ const addChild = (dir, child) => {
162
+ if (!this.childrenByDir.has(dir)) this.childrenByDir.set(dir, new Set());
163
+ this.childrenByDir.get(dir).add(child);
164
+ };
165
+
166
+ for (const file of this.db.listAll()) {
167
+ const logical = normalizeLogicalPath(file.filename);
168
+ this.filePaths.add(logical);
169
+ if (!this.fileByLogicalPath.has(logical)) this.fileByLogicalPath.set(logical, file);
170
+ const parts = logical.split('/');
171
+ let dir = '';
172
+ for (let index = 0; index < parts.length; index++) {
173
+ addChild(dir, parts[index]);
174
+ if (index < parts.length - 1) {
175
+ dir = dir ? `${dir}/${parts[index]}` : parts[index];
176
+ this.implicitDirs.add(dir);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ _file(logicalPath) {
183
+ return this.fileByLogicalPath.get(logicalPath);
184
+ }
185
+
186
+ _newWritePath(logicalPath) {
187
+ const dir = path.join(this.dataDir, 'fuse-writes');
188
+ fs.mkdirSync(dir, { recursive: true });
189
+ const safe = Buffer.from(logicalPath).toString('hex').slice(0, 48) || 'root';
190
+ return path.join(dir, `${safe}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
191
+ }
192
+
193
+ async _ensureWriteFile(logicalPath, { empty = false } = {}) {
194
+ if (this.writeBuffers.has(logicalPath)) return this.writeBuffers.get(logicalPath);
195
+ const tempPath = this._newWritePath(logicalPath);
196
+ if (empty) {
197
+ fs.closeSync(fs.openSync(tempPath, 'w', 0o600));
198
+ } else {
199
+ const existing = this._file(logicalPath);
200
+ if (existing) {
201
+ const cachedPath = await this.downloadFileToCache(logicalPath);
202
+ fs.copyFileSync(cachedPath, tempPath);
203
+ } else {
204
+ fs.closeSync(fs.openSync(tempPath, 'w', 0o600));
205
+ }
206
+ }
207
+ const entry = { path: tempPath, modified: true, isNew: !this._file(logicalPath) };
208
+ this.writeBuffers.set(logicalPath, entry);
209
+ return entry;
64
210
  }
65
211
 
66
212
  /**
67
213
  * Get file attributes
68
214
  */
69
215
  getattr(filepath, cb) {
70
- const filename = path.basename(filepath);
216
+ let logical;
217
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
71
218
 
72
- // Root directory
73
- if (filepath === '/') {
219
+ if (this._isDirectory(logical)) {
74
220
  return cb(0, {
75
221
  mtime: new Date(),
76
222
  atime: new Date(),
@@ -83,13 +229,14 @@ export class TelegramFS {
83
229
  }
84
230
 
85
231
  // Check write buffers first (new/pending files)
86
- const wb = this.writeBuffers.get(filename);
232
+ const wb = this.writeBuffers.get(logical);
87
233
  if (wb) {
234
+ const stats = fs.statSync(wb.path);
88
235
  return cb(0, {
89
236
  mtime: new Date(),
90
237
  atime: new Date(),
91
238
  ctime: new Date(),
92
- size: wb.data.length,
239
+ size: stats.size,
93
240
  mode: 0o100644, // regular file
94
241
  uid: process.getuid?.() || 0,
95
242
  gid: process.getgid?.() || 0
@@ -97,7 +244,7 @@ export class TelegramFS {
97
244
  }
98
245
 
99
246
  // Look up file in index
100
- const file = this.db.findByName(filename);
247
+ const file = this._file(logical);
101
248
 
102
249
  if (!file) {
103
250
  return cb(Fuse.ENOENT);
@@ -118,28 +265,31 @@ export class TelegramFS {
118
265
  * List directory contents
119
266
  */
120
267
  readdir(filepath, cb) {
121
- if (filepath !== '/') {
122
- return cb(Fuse.ENOENT);
123
- }
268
+ let logical;
269
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
270
+ if (!this._isDirectory(logical)) return cb(Fuse.ENOENT);
124
271
 
125
- const files = this.db.listAll();
126
- const names = files.map(f => f.filename);
127
-
128
- return cb(0, names);
272
+ const names = new Set(this.childrenByDir.get(logical) || []);
273
+ for (const name of listLogicalChildren([...this.writeBuffers.keys(), ...this.virtualDirs].filter(Boolean), logical)) {
274
+ names.add(name);
275
+ }
276
+ return cb(0, [...names].sort((a, b) => a.localeCompare(b)));
129
277
  }
130
278
 
131
279
  /**
132
280
  * Open a file (just validates it exists)
133
281
  */
134
282
  open(filepath, flags, cb) {
135
- const filename = path.basename(filepath);
283
+ let logical;
284
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
285
+ if (this._isDirectory(logical)) return cb(Fuse.EISDIR || Fuse.EINVAL);
136
286
 
137
287
  // Check if it's a new file being written
138
- if (this.writeBuffers.has(filename)) {
288
+ if (this.writeBuffers.has(logical)) {
139
289
  return cb(0, 42); // Return a dummy fd
140
290
  }
141
291
 
142
- const file = this.db.findByName(filename);
292
+ const file = this._file(logical);
143
293
 
144
294
  if (!file) {
145
295
  return cb(Fuse.ENOENT);
@@ -152,24 +302,26 @@ export class TelegramFS {
152
302
  * Read file contents from disk cache
153
303
  */
154
304
  async read(filepath, fd, buffer, length, position, cb) {
155
- const filename = path.basename(filepath);
305
+ let logical;
306
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
156
307
 
157
308
  try {
158
309
  // Check write buffers first
159
- const wb = this.writeBuffers.get(filename);
310
+ const wb = this.writeBuffers.get(logical);
160
311
  if (wb) {
161
- const slice = wb.data.subarray(position, position + length);
162
- slice.copy(buffer);
163
- return cb(slice.length);
312
+ const writeFd = fs.openSync(wb.path, 'r');
313
+ const bytesRead = fs.readSync(writeFd, buffer, 0, length, position);
314
+ fs.closeSync(writeFd);
315
+ return cb(bytesRead);
164
316
  }
165
317
 
166
318
  // Check cache first
167
- let cachedPath = this.getCached(filename);
319
+ let cachedPath = this.getCached(logical);
168
320
 
169
321
  if (!cachedPath) {
170
322
  // Download, decrypt, and save to disk cache
171
- cachedPath = await this.downloadFileToCache(filename);
172
- this.setCache(filename, cachedPath);
323
+ cachedPath = await this.downloadFileToCache(logical);
324
+ this.setCache(logical, cachedPath);
173
325
  }
174
326
 
175
327
  // Copy requested portion to buffer from disk
@@ -187,50 +339,32 @@ export class TelegramFS {
187
339
  /**
188
340
  * Write to a file (buffers until release)
189
341
  */
190
- write(filepath, fd, buffer, length, position, cb) {
191
- const filename = path.basename(filepath);
192
-
193
- // Get or create write buffer
194
- if (!this.writeBuffers.has(filename)) {
195
- this.writeBuffers.set(filename, {
196
- data: Buffer.alloc(0),
197
- modified: true
198
- });
199
- }
200
-
201
- const wb = this.writeBuffers.get(filename);
202
-
203
- // Expand buffer if needed
204
- const newSize = Math.max(wb.data.length, position + length);
205
- if (newSize > wb.data.length) {
206
- const newBuf = Buffer.alloc(newSize);
207
- wb.data.copy(newBuf);
208
- wb.data = newBuf;
342
+ async write(filepath, fd, buffer, length, position, cb) {
343
+ let logical;
344
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
345
+ try {
346
+ const wb = await this._ensureWriteFile(logical);
347
+ const writeFd = fs.openSync(wb.path, 'r+');
348
+ fs.writeSync(writeFd, buffer, 0, length, position);
349
+ fs.closeSync(writeFd);
350
+ wb.modified = true;
351
+ return cb(length);
352
+ } catch (error) {
353
+ console.error('Write error:', error.message);
354
+ return cb(Fuse.EIO);
209
355
  }
210
-
211
- // Copy incoming data
212
- buffer.copy(wb.data, position, 0, length);
213
- wb.modified = true;
214
-
215
- return cb(length);
216
356
  }
217
357
 
218
358
  /**
219
359
  * Create a new file
220
360
  */
221
361
  create(filepath, mode, cb) {
222
- const filename = path.basename(filepath);
223
-
224
- console.log(`[FUSE] Creating file: ${filename}`);
225
-
226
- // Initialize empty write buffer
227
- this.writeBuffers.set(filename, {
228
- data: Buffer.alloc(0),
229
- modified: true,
230
- isNew: true
231
- });
232
-
233
- return cb(0, 42); // Return a valid fd
362
+ let logical;
363
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
364
+ console.log(`[FUSE] Creating file: ${logical}`);
365
+ this._ensureWriteFile(logical, { empty: true })
366
+ .then(() => cb(0, 42))
367
+ .catch(() => cb(Fuse.EIO));
234
368
  }
235
369
 
236
370
  /**
@@ -244,22 +378,24 @@ export class TelegramFS {
244
378
  * Flush/sync file to Telegram
245
379
  */
246
380
  async release(filepath, fd, cb) {
247
- const filename = path.basename(filepath);
381
+ let logical;
382
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
248
383
 
249
- const wb = this.writeBuffers.get(filename);
384
+ const wb = this.writeBuffers.get(logical);
250
385
  if (!wb || !wb.modified) {
251
386
  return cb(0);
252
387
  }
253
388
 
254
389
  try {
255
390
  // Upload to Telegram
256
- await this.uploadFile(filename, wb.data);
391
+ await this.uploadFile(logical, wb.path);
257
392
 
258
393
  // Clear write buffer
259
- this.writeBuffers.delete(filename);
394
+ this.writeBuffers.delete(logical);
395
+ try { fs.unlinkSync(wb.path); } catch { }
260
396
 
261
397
  // Invalidate cache
262
- this.invalidateCache(filename);
398
+ this.invalidateCache(logical);
263
399
 
264
400
  return cb(0);
265
401
  } catch (err) {
@@ -272,25 +408,40 @@ export class TelegramFS {
272
408
  * Delete a file
273
409
  */
274
410
  async unlink(filepath, cb) {
275
- const filename = path.basename(filepath);
276
- const file = this.db.findByName(filename);
411
+ let logical;
412
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
413
+ const file = this._file(logical);
277
414
 
278
415
  if (!file) {
279
416
  return cb(Fuse.ENOENT);
280
417
  }
281
418
 
282
419
  try {
283
- // Delete from Telegram (optional - could just remove from index)
284
420
  const chunks = this.db.getChunks(file.id);
285
- for (const chunk of chunks) {
286
- await this.client.deleteMessage(chunk.message_id);
421
+ const before = this.db.exportManifest({ includeShares: true });
422
+ this.db.delete(file.id);
423
+ this._refreshPathIndex();
424
+
425
+ try {
426
+ await this.backupManifest({
427
+ dataDir: this.dataDir,
428
+ password: this.password,
429
+ config: this.config,
430
+ telegramPool: this.client
431
+ });
432
+ } catch (error) {
433
+ this.db.importManifest(before);
434
+ this._refreshPathIndex();
435
+ throw error;
287
436
  }
288
437
 
289
- // Remove from index
290
- this.db.delete(file.id);
438
+ // Delete remote messages only after the new recovery point is durable.
439
+ for (const chunk of chunks) {
440
+ await this.client.deleteMessage(chunk.message_id, chunk.bot_id || null);
441
+ }
291
442
 
292
443
  // Invalidate cache
293
- this.invalidateCache(filename);
444
+ this.invalidateCache(logical);
294
445
 
295
446
  return cb(0);
296
447
  } catch (err) {
@@ -302,18 +453,61 @@ export class TelegramFS {
302
453
  /**
303
454
  * Rename/move a file (just update index, data stays in Telegram)
304
455
  */
305
- rename(src, dest, cb) {
306
- const oldName = path.basename(src);
307
- const newName = path.basename(dest);
456
+ async rename(src, dest, cb) {
457
+ let oldName;
458
+ let newName;
459
+ try {
460
+ oldName = this._logical(src);
461
+ newName = this._logical(dest);
462
+ } catch {
463
+ return cb(Fuse.EINVAL);
464
+ }
465
+
466
+ if (oldName === newName) return cb(0);
308
467
 
309
- const file = this.db.findByName(oldName);
468
+ const pendingWrite = this.writeBuffers.get(oldName);
469
+ if (pendingWrite) {
470
+ this.writeBuffers.delete(oldName);
471
+ this.writeBuffers.set(newName, pendingWrite);
472
+ return cb(0);
473
+ }
474
+
475
+ const file = this._file(oldName);
310
476
  if (!file) {
311
477
  return cb(Fuse.ENOENT);
312
478
  }
313
479
 
480
+ // Avoid duplicate filenames: remove the destination first
481
+ try {
482
+ const destFile = this._file(newName);
483
+ if (destFile && destFile.id !== file.id) {
484
+ const destChunks = this.db.getChunks(destFile.id);
485
+ for (const chunk of destChunks) {
486
+ try { await this.client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch (e) { }
487
+ }
488
+ this.db.deleteFileCascade(destFile.id);
489
+ this.invalidateCache(newName);
490
+ }
491
+ } catch (e) { /* best effort */ }
492
+
314
493
  // Update filename in database
315
494
  this.db.db.prepare('UPDATE files SET filename = ? WHERE id = ?')
316
495
  .run(newName, file.id);
496
+ this._refreshPathIndex();
497
+
498
+ try {
499
+ await this.backupManifest({
500
+ dataDir: this.dataDir,
501
+ password: this.password,
502
+ config: this.config,
503
+ telegramPool: this.client
504
+ });
505
+ } catch (error) {
506
+ console.error('Remote manifest update failed after rename:', error.message);
507
+ this.db.db.prepare('UPDATE files SET filename = ? WHERE id = ?').run(oldName, file.id);
508
+ this._refreshPathIndex();
509
+ return cb(Fuse.EIO);
510
+ }
317
511
 
318
512
  // Update cache key
319
513
  const cached = fileCache.get(oldName);
@@ -325,46 +519,50 @@ export class TelegramFS {
325
519
  return cb(0);
326
520
  }
327
521
 
522
+ mkdir(filepath, mode, cb) {
523
+ let logical;
524
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
525
+ if (this._file(logical) || this._isDirectory(logical)) return cb(Fuse.EEXIST || Fuse.EINVAL);
526
+ const parent = parentLogicalPath(logical);
527
+ if (!this._isDirectory(parent)) return cb(Fuse.ENOENT);
528
+ this.virtualDirs.add(logical);
529
+ return cb(0);
530
+ }
531
+
532
+ rmdir(filepath, cb) {
533
+ let logical;
534
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
535
+ if (!this._isDirectory(logical)) return cb(Fuse.ENOENT);
536
+ if (listLogicalChildren([...this._allLogicalPaths(), ...this.virtualDirs].filter(Boolean), logical).length > 0) {
537
+ return cb(Fuse.ENOTEMPTY || Fuse.EINVAL);
538
+ }
539
+ this.virtualDirs.delete(logical);
540
+ return cb(0);
541
+ }
542
+
328
543
  /**
329
544
  * Truncate a file
330
545
  */
331
- truncate(filepath, size, cb) {
332
- const filename = path.basename(filepath);
333
-
334
- // Get or load into write buffer
335
- if (!this.writeBuffers.has(filename)) {
336
- const cachedPath = this.getCached(filename);
337
- if (cachedPath) {
338
- this.writeBuffers.set(filename, {
339
- data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
340
- modified: true
341
- });
342
- } else {
343
- this.writeBuffers.set(filename, {
344
- data: Buffer.alloc(0),
345
- modified: true
346
- });
347
- }
348
- }
349
-
350
- const wb = this.writeBuffers.get(filename);
351
-
352
- if (size < wb.data.length) {
353
- wb.data = wb.data.subarray(0, size);
354
- } else if (size > wb.data.length) {
355
- const newBuf = Buffer.alloc(size);
356
- wb.data.copy(newBuf);
357
- wb.data = newBuf;
546
+ async truncate(filepath, size, cb) {
547
+ let logical;
548
+ try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
549
+ try {
550
+ // Always hydrate a remote file before truncating it. Falling back
551
+ // to an empty buffer silently destroyed uncached content.
552
+ const wb = await this._ensureWriteFile(logical);
553
+ fs.truncateSync(wb.path, size);
554
+ wb.modified = true;
555
+ return cb(0);
556
+ } catch (error) {
557
+ console.error('Truncate error:', error.message);
558
+ return cb(Fuse.EIO);
358
559
  }
359
-
360
- wb.modified = true;
361
- return cb(0);
362
560
  }
363
561
 
364
562
  // ============== Helper Methods ==============
365
563
 
366
564
  async downloadFileToCache(filename) {
367
- const file = this.db.findByName(filename);
565
+ const file = this._file(filename);
368
566
  if (!file) throw new Error('File not found');
369
567
 
370
568
  const cacheDir = path.join(this.dataDir, 'cache');
@@ -402,68 +600,29 @@ export class TelegramFS {
402
600
  return outputPath;
403
601
  }
404
602
 
405
- async uploadFile(filename, data) {
406
- const { hashData } = await import('../crypto/encryption.js');
407
- const hash = hashData(data);
408
-
409
- // Check if already exists by name
410
- const existingByName = this.db.findByName(filename);
411
- if (existingByName) {
412
- // Delete old version
413
- const chunks = this.db.getChunks(existingByName.id);
414
- for (const chunk of chunks) {
415
- try { await this.client.deleteMessage(chunk.message_id); } catch (e) { }
416
- }
417
- this.db.delete(existingByName.id);
418
- }
419
-
420
- // Check if already exists by hash (same content, different name)
421
- const existingByHash = this.db.findByHash(hash);
422
- if (existingByHash) {
423
- // Same content already exists, just skip
424
- console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
603
+ async uploadFile(filename, sourcePath) {
604
+ const existing = this._file(filename);
605
+ const hash = await hashFile(sourcePath);
606
+ if (existing?.hash === hash) {
607
+ await this.backupManifest({
608
+ dataDir: this.dataDir,
609
+ password: this.password,
610
+ config: this.config,
611
+ telegramPool: this.client
612
+ });
425
613
  return;
426
614
  }
427
615
 
428
- // Compress
429
- const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
430
-
431
- // Encrypt
432
- const encryptedData = this.encryptor.encrypt(compressedData);
433
-
434
- // Create temp file with header
435
- const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
436
- if (!fs.existsSync(tempDir)) {
437
- fs.mkdirSync(tempDir, { recursive: true });
438
- }
439
-
440
- const flags = compressed ? 1 : 0;
441
- const header = createHeader(filename, data.length, 0, 1, flags);
442
- const fileData = Buffer.concat([header, encryptedData]);
443
-
444
- const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
445
- fs.writeFileSync(tempPath, fileData);
446
-
447
- // Upload to Telegram
448
- const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
449
-
450
- // Add to index
451
- const fileId = this.db.addFile({
452
- filename,
453
- hash,
454
- originalSize: data.length,
455
- storedSize: encryptedData.length,
456
- chunks: 1,
457
- compressed
616
+ const result = await processFile(sourcePath, {
617
+ password: this.password,
618
+ dataDir: this.dataDir,
619
+ customName: filename,
620
+ config: this.config,
621
+ telegramPool: this.client,
622
+ replaceExisting: true
458
623
  });
459
-
460
- this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
461
- this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
462
- .run(result.fileId, fileId, 0);
463
-
464
- // Cleanup
465
- fs.unlinkSync(tempPath);
466
- try { fs.rmdirSync(tempDir); } catch (e) { }
624
+ this._refreshPathIndex();
625
+ if (result.manifestWarning) throw new Error(`Remote recovery manifest failed: ${result.manifestWarning}`);
467
626
  }
468
627
 
469
628
  getCached(filename) {
@@ -537,6 +696,8 @@ export class TelegramFS {
537
696
  release: this.release.bind(this),
538
697
  unlink: this.unlink.bind(this),
539
698
  rename: this.rename.bind(this),
699
+ mkdir: this.mkdir.bind(this),
700
+ rmdir: this.rmdir.bind(this),
540
701
  truncate: this.truncate.bind(this),
541
702
  ftruncate: this.ftruncate.bind(this)
542
703
  };