@nightowne/tas-cli 2.4.1 → 3.0.1

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