@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/CHANGELOG.md +162 -0
- package/FAQ.md +33 -0
- package/QUICKSTART.md +47 -0
- package/README.md +94 -93
- package/package.json +12 -5
- package/src/cli.js +760 -214
- package/src/db/index.js +307 -24
- package/src/fuse/macfuse.cjs +235 -0
- package/src/fuse/mount.js +338 -175
- package/src/index.js +208 -150
- package/src/manifest.js +104 -0
- package/src/share/server.js +7 -9
- package/src/sync/sync.js +105 -34
- package/src/telegram/client.js +13 -12
- package/src/telegram/pool.js +105 -0
- package/src/utils/branding.js +2 -2
- package/src/utils/chunker.js +4 -3
- package/src/utils/cli-helpers.js +90 -10
- package/src/utils/download-stream.js +10 -4
- package/src/utils/logical-path.js +44 -0
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 {
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
218
|
+
let logical;
|
|
219
|
+
try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
|
|
71
220
|
|
|
72
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
290
|
+
if (this.writeBuffers.has(logical)) {
|
|
139
291
|
return cb(0, 42); // Return a dummy fd
|
|
140
292
|
}
|
|
141
293
|
|
|
142
|
-
const file = this.
|
|
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
|
-
|
|
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(
|
|
312
|
+
const wb = this.writeBuffers.get(logical);
|
|
160
313
|
if (wb) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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(
|
|
172
|
-
this.setCache(
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
console.log(`[FUSE] Creating file: ${
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
383
|
+
let logical;
|
|
384
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
248
385
|
|
|
249
|
-
const wb = this.writeBuffers.get(
|
|
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(
|
|
393
|
+
await this.uploadFile(logical, wb.path);
|
|
257
394
|
|
|
258
395
|
// Clear write buffer
|
|
259
|
-
this.writeBuffers.delete(
|
|
396
|
+
this.writeBuffers.delete(logical);
|
|
397
|
+
try { fs.unlinkSync(wb.path); } catch { }
|
|
260
398
|
|
|
261
399
|
// Invalidate cache
|
|
262
|
-
this.invalidateCache(
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
290
|
-
|
|
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(
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|
|
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,
|
|
406
|
-
const
|
|
407
|
-
const hash =
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
};
|