telstore 0.1.9 → 0.1.11
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/README.md +117 -5
- package/bin/telstore.js +271 -4
- package/package.json +1 -1
- package/src/caption.js +70 -3
- package/src/cipher.js +211 -0
- package/src/cli.js +321 -13
- package/src/client.js +7 -1
- package/src/commands/delete.js +333 -41
- package/src/commands/down.js +311 -0
- package/src/commands/join.js +273 -0
- package/src/commands/list.js +12 -35
- package/src/commands/restore-stream.js +434 -0
- package/src/commands/restore.js +73 -41
- package/src/commands/status.js +196 -20
- package/src/commands/upload-stream.js +507 -0
- package/src/commands/upload.js +212 -29
- package/src/commands/verify.js +9 -6
- package/src/manifest.js +161 -5
- package/src/password.js +101 -0
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +102 -2
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
- package/src/uploader.js +6 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { describeChat } from '../chat.js'
|
|
6
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
7
|
+
import { askConfirm } from '../confirm.js'
|
|
8
|
+
import { deleteCommand, shellArg } from '../shell.js'
|
|
9
|
+
import { listRestores, listStates } from '../state.js'
|
|
10
|
+
|
|
11
|
+
// Nothing here may reach src/client.js, directly or through a command that does: `down` is
|
|
12
|
+
// run on a machine somebody is finished with, often one with no network and sometimes one
|
|
13
|
+
// they no longer trust. It opens no socket, and test/bin.test.js measures that it stays
|
|
14
|
+
// that way — a single import of status.js would put teleproto back in the path.
|
|
15
|
+
|
|
16
|
+
const LABEL_WIDTH = 'Destination'.length + 2
|
|
17
|
+
|
|
18
|
+
function row(label, value) {
|
|
19
|
+
return ` ${label.padEnd(LABEL_WIDTH)}${value}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// status prints this same count about this same directory. It cannot be imported from there
|
|
23
|
+
// without dragging teleproto in, so it is copied — and copied exactly, because two commands
|
|
24
|
+
// disagreeing about what is unfinished on one machine is worse than either wording alone.
|
|
25
|
+
function unfinishedCount(uploads, restores) {
|
|
26
|
+
const parts = []
|
|
27
|
+
|
|
28
|
+
if (uploads > 0) parts.push(`${uploads} upload${uploads === 1 ? '' : 's'}`)
|
|
29
|
+
if (restores > 0) parts.push(`${restores} restore${restores === 1 ? '' : 's'}`)
|
|
30
|
+
|
|
31
|
+
return parts.length === 0 ? 'none' : parts.join(', ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A recursive remove is the one mistake in this command nobody could apologise for, and the
|
|
35
|
+
// path it walks is derived rather than typed — os.homedir() reading an empty HOME is all it
|
|
36
|
+
// takes. These two are not reachable by any correct call, which is exactly why they are
|
|
37
|
+
// worth refusing by name rather than trusting the caller.
|
|
38
|
+
function refuseDangerousTarget(dir) {
|
|
39
|
+
if (dir === os.homedir() || path.dirname(dir) === dir) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`telstore refuses to remove ${dir}: that is a home directory or a filesystem root, ` +
|
|
42
|
+
'not a telstore config directory. Nothing was removed.',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The config is read to describe it, never to trust it. loadConfig throws on a file that is
|
|
48
|
+
// corrupt, that holds a `settings` which is not a group, or that holds both a sealed session
|
|
49
|
+
// and a plain one — and its own advice for all three is to delete the file and log in again.
|
|
50
|
+
// This is the command that does that, so it is the one command a broken config must not stop.
|
|
51
|
+
async function describeConfig(configDir) {
|
|
52
|
+
try {
|
|
53
|
+
return { config: await loadConfig(configDir), error: null }
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { config: {}, error: err.message }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The error itself is deliberately not quoted here. Every message loadConfig throws names the
|
|
60
|
+
// config file, every path to it contains a dot, and a first-sentence split therefore cuts
|
|
61
|
+
// "/home/sho/.telstore/config.json is not valid JSON" down to "/home/sho/" — a mangled path
|
|
62
|
+
// presented as a diagnosis. The whole message is no better: its advice is "delete the file and
|
|
63
|
+
// log in again", which is what this command is in the middle of doing.
|
|
64
|
+
function sessionLine(config, error) {
|
|
65
|
+
if (error) return 'cannot be read — whatever is in that file, it goes with the rest'
|
|
66
|
+
if (config.sealed) return 'sealed — the api_id and api_hash are inside it, so they go too'
|
|
67
|
+
if (config.session) return 'stored here in plain text — api_id and api_hash go with it'
|
|
68
|
+
|
|
69
|
+
return 'not logged in'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// What an unfinished upload was of — either kind of record, which is why this is not the
|
|
73
|
+
// `describeStreamSource` `status` has: that one answers only for a stream record, and one
|
|
74
|
+
// name over two answers is how the two came to disagree. A stream record has no path — its
|
|
75
|
+
// bytes came from a command's stdout — and carries the name the backup was given instead.
|
|
76
|
+
// This listing exists so that nothing goes unnamed before a recursive remove, so a row
|
|
77
|
+
// reading "undefined" is the exact failure it is here to prevent.
|
|
78
|
+
//
|
|
79
|
+
// Blank is the same failure wearing a string's clothes: a record holding `name: " "` would
|
|
80
|
+
// print ` (a command's output)` — a row that names nothing, in the one command whose job
|
|
81
|
+
// is that nothing goes unnamed. Trimmed for that, exactly as `status` trims, and both fields
|
|
82
|
+
// get the rule because a whitespace path is no more a name than a whitespace name is.
|
|
83
|
+
function describeRecordSource(state) {
|
|
84
|
+
if (typeof state.path === 'string' && state.path.trim() !== '') return state.path
|
|
85
|
+
if (typeof state.name === 'string' && state.name.trim() !== '') {
|
|
86
|
+
return `${state.name} (a command's output)`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return 'a record that does not say what it was backing up'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The last thing anything anywhere will say about those chunks. down.md prints a resume
|
|
93
|
+
// command for a .partial "because this is the last time anything will mention that file", and
|
|
94
|
+
// a stream record is the sharper case: it cannot be re-run onto — those bytes have gone past —
|
|
95
|
+
// so once this record is gone nothing on this machine lists those message ids and no manifest
|
|
96
|
+
// in the chat names them. Printing the command removes nothing and opens no socket; it is the
|
|
97
|
+
// naming this whole listing exists for, done for something that lives on Telegram.
|
|
98
|
+
//
|
|
99
|
+
// The chat is named for the reason status names it, and the line itself is built by
|
|
100
|
+
// `deleteCommand` in shell.js so that all four places that print it spell `--chat` the same
|
|
101
|
+
// way. The check in front of it is this command's own decision and stays here: a record that
|
|
102
|
+
// cannot say where its chunks went gets no command at all rather than the chatless one that
|
|
103
|
+
// function would otherwise hand back, because here that would be a guess — `--chat` missing is
|
|
104
|
+
// not `--chat` empty, and runDelete would take it as no destination and resolve one from
|
|
105
|
+
// config. The id needs no guard — the listing above measures state.id.length for its own
|
|
106
|
+
// column, so a record without one never reaches this line.
|
|
107
|
+
function removeCommand(state) {
|
|
108
|
+
const chat = state.chat === null || state.chat === undefined ? '' : String(state.chat).trim()
|
|
109
|
+
|
|
110
|
+
if (chat === '') return null
|
|
111
|
+
|
|
112
|
+
return deleteCommand(state.id, chat)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Only the ones actually on disk. A restore record survives a .partial that was deleted by
|
|
116
|
+
// hand, and pointing at a file that is not there sends somebody looking for nothing.
|
|
117
|
+
async function strandedPartials(restores) {
|
|
118
|
+
const found = []
|
|
119
|
+
|
|
120
|
+
for (const { record } of restores) {
|
|
121
|
+
const partial = `${record.target}.partial`
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await fs.stat(partial)
|
|
125
|
+
found.push({ partial, record })
|
|
126
|
+
} catch {
|
|
127
|
+
// Nothing there to tell them about.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return found
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Everything in the directory that telstore did not put there. The whole directory goes
|
|
135
|
+
// either way — it is what was asked for — but delete's rule holds here too: nothing is
|
|
136
|
+
// removed unnamed.
|
|
137
|
+
//
|
|
138
|
+
// `tmp` is telstore's too: a stream upload borrows one chunk of disk at a time under
|
|
139
|
+
// ~/.telstore rather than /tmp, which is tmpfs on many distributions and would turn a chunk
|
|
140
|
+
// size into a memory limit. Naming it here would be down reporting its own working directory
|
|
141
|
+
// as a stranger's file — and if a run died mid-chunk it may hold up to one chunk, which the
|
|
142
|
+
// recursive remove below takes with everything else.
|
|
143
|
+
async function foreignEntries(configDir) {
|
|
144
|
+
const ours = new Set(['config.json', 'config.json.tmp', 'state', 'tmp'])
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
return (await fs.readdir(configDir)).filter((name) => !ours.has(name))
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function runDown(args = [], options = {}, deps = {}) {
|
|
154
|
+
const {
|
|
155
|
+
configDir = defaultConfigDir(),
|
|
156
|
+
confirm = askConfirm,
|
|
157
|
+
interactive = () => Boolean(process.stdin.isTTY),
|
|
158
|
+
log = (line) => console.log(line),
|
|
159
|
+
} = deps
|
|
160
|
+
|
|
161
|
+
// `telstore down telstore-20260905-7f3a91` is the plausible typo — somebody reaching for
|
|
162
|
+
// the command that removes one backup. Obeying it would wipe the machine instead.
|
|
163
|
+
if (args.length > 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`down takes no arguments, but got "${args.join(' ')}". It removes everything on this ` +
|
|
166
|
+
'machine or nothing. To remove one backup from Telegram, use ' +
|
|
167
|
+
'"npx telstore delete <backup-id>".',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
refuseDangerousTarget(configDir)
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await fs.stat(configDir)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err.code !== 'ENOENT') throw err
|
|
177
|
+
|
|
178
|
+
log(`Nothing to remove: ${configDir} is not there.`)
|
|
179
|
+
return { removed: false, dir: configDir }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { config, error } = await describeConfig(configDir)
|
|
183
|
+
const uploads = await listStates(configDir)
|
|
184
|
+
const restores = await listRestores(configDir)
|
|
185
|
+
const stranded = await strandedPartials(restores)
|
|
186
|
+
const foreign = await foreignEntries(configDir)
|
|
187
|
+
const chat = config.settings?.chat ?? null
|
|
188
|
+
|
|
189
|
+
log('This removes everything telstore keeps on this machine.')
|
|
190
|
+
log('')
|
|
191
|
+
log(row('Directory', configDir))
|
|
192
|
+
log(row('Session', sessionLine(config, error)))
|
|
193
|
+
if (chat !== null) log(row('Destination', describeChat(chat)))
|
|
194
|
+
log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
|
|
195
|
+
if (foreign.length > 0) log(row('Also there', foreign.join(', ')))
|
|
196
|
+
|
|
197
|
+
// The expensive half of what is about to go. An upload record is what lets a second run
|
|
198
|
+
// keep the same backupId and skip the chunks already sent; without it the same file goes
|
|
199
|
+
// up again as a new backup, and the chunks already in the chat stay there under an id
|
|
200
|
+
// nothing on this machine remembers. So the ids are said out loud while someone can read
|
|
201
|
+
// them. A restore record costs nothing to lose — the .partial resumes without it — and is
|
|
202
|
+
// dealt with on the way out instead.
|
|
203
|
+
if (uploads.length > 0) {
|
|
204
|
+
const width = Math.max(...uploads.map(({ state }) => state.id.length))
|
|
205
|
+
|
|
206
|
+
log('')
|
|
207
|
+
log('These uploads have not finished. Their records are the only thing that lets a second')
|
|
208
|
+
log('run carry on: without them the same file goes up again as a new backup, and the chunks')
|
|
209
|
+
log('already sent stay in the chat under these ids and nothing else:')
|
|
210
|
+
log('')
|
|
211
|
+
for (const { state } of uploads) {
|
|
212
|
+
log(` ${state.id.padEnd(width)} ${describeRecordSource(state)}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const streams = uploads.filter(({ state }) => state.kind === 'stream')
|
|
216
|
+
|
|
217
|
+
if (streams.length > 0) {
|
|
218
|
+
log('')
|
|
219
|
+
log('The ones marked as a command\'s output cannot be carried on at all: those bytes have')
|
|
220
|
+
log('gone past, and a second run cuts them differently. Their records are the only list of')
|
|
221
|
+
log('the chunks those runs left in the chat, and nothing replaces them — so this is the')
|
|
222
|
+
log('last chance to copy the commands that remove those chunks:')
|
|
223
|
+
log('')
|
|
224
|
+
|
|
225
|
+
for (const { state } of streams) {
|
|
226
|
+
const command = removeCommand(state)
|
|
227
|
+
|
|
228
|
+
if (command === null) {
|
|
229
|
+
log(` ${state.id}`)
|
|
230
|
+
log(' this record does not say which chat its chunks went to, so there is no')
|
|
231
|
+
log(' command that could reach them')
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
log(` ${command}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
log('')
|
|
241
|
+
log('Nothing on Telegram is touched: every backup stays where it is.')
|
|
242
|
+
|
|
243
|
+
if (!options.yes) {
|
|
244
|
+
if (!interactive()) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Nothing was removed: there is no terminal to confirm in. Run again with --yes to ` +
|
|
247
|
+
`remove ${configDir} without being asked.`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
log('')
|
|
252
|
+
|
|
253
|
+
if (!(await confirm(`This cannot be undone. Remove ${configDir}? [y/N] `))) {
|
|
254
|
+
throw new Error('Cancelled on request.')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await fs.rm(configDir, { recursive: true, force: true })
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// fs.rm walks the tree, so a permission error halfway leaves some of it gone. Reporting
|
|
262
|
+
// that as a success is the one thing this project never does.
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Could not remove ${configDir}: ${err.message}. Some of it may already be gone — ` +
|
|
265
|
+
'run "npx telstore down" again once the permissions allow it.',
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
log('')
|
|
270
|
+
log(`Done. Removed ${configDir}.`)
|
|
271
|
+
|
|
272
|
+
if (config.session || config.sealed || error) {
|
|
273
|
+
log(
|
|
274
|
+
'Note: this only deletes the local copy — the session is still alive on Telegram\'s ' +
|
|
275
|
+
'side. To revoke access for good, open Telegram → Settings → Devices (Active ' +
|
|
276
|
+
'sessions) and terminate that session.',
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (config.apiId || config.apiHash || config.sealed) {
|
|
281
|
+
log(
|
|
282
|
+
'api_id and api_hash are gone too, which logout would have kept: the next ' +
|
|
283
|
+
'"npx telstore login" asks for them again (my.telegram.org).',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
log(
|
|
288
|
+
chat === null
|
|
289
|
+
? 'Your backups are still on Telegram. Log in again and "npx telstore list" finds them.'
|
|
290
|
+
: `Your backups are still in ${describeChat(chat)} — log in again and ` +
|
|
291
|
+
'"npx telstore list" finds them.',
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
// The .partial is the user's data, and down removes what was asked for and nothing else.
|
|
295
|
+
// Unlike after a delete it can still be finished: the chunks and the manifest a resume
|
|
296
|
+
// checks against were never touched. What is gone is the record that listed it, so this
|
|
297
|
+
// is the last time anything will mention the file at all.
|
|
298
|
+
for (const { partial, record } of stranded) {
|
|
299
|
+
const chatFlag = record.chat ? ` --chat ${shellArg(record.chat)}` : ''
|
|
300
|
+
|
|
301
|
+
log('')
|
|
302
|
+
log(`${partial} is a half-finished restore of ${record.id}, and is left where it is.`)
|
|
303
|
+
log('It still resumes — log in again and run it from anywhere:')
|
|
304
|
+
log(
|
|
305
|
+
` npx telstore restore ${shellArg(record.id)} --out ${shellArg(record.target)}${chatFlag}`,
|
|
306
|
+
)
|
|
307
|
+
log('Nothing else will remind you: the record that listed it went with the rest.')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { removed: true, dir: configDir, uploads: uploads.length, restores: restores.length }
|
|
311
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { askConfirm } from '../confirm.js'
|
|
6
|
+
import { chunkCipher } from '../cipher.js'
|
|
7
|
+
import { chunkFileName, isEncrypted, parseManifest, safeOutName } from '../manifest.js'
|
|
8
|
+
import { askPassword as realAskPassword, unlockManifest } from '../password.js'
|
|
9
|
+
import { createProgress, formatBytes } from '../progress.js'
|
|
10
|
+
|
|
11
|
+
// Big enough that a 1800MB chunk is a couple of hundred reads, small enough that one buffer
|
|
12
|
+
// reused for every chunk is nothing next to the file being written.
|
|
13
|
+
const COPY_SIZE = 8 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
// A backup id becomes part of a path below, and it comes out of a file a person downloaded
|
|
16
|
+
// and can edit. Every id telstore writes is `telstore-YYYYMMDD-<hex>`; anything carrying a
|
|
17
|
+
// separator or a leading dot would have join read chunks from somewhere other than beside
|
|
18
|
+
// the manifest, so it is refused rather than followed.
|
|
19
|
+
function assertPlainId(id) {
|
|
20
|
+
if (typeof id !== 'string' || !/^[\w.-]+$/.test(id) || id.startsWith('.')) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`The manifest gives ${JSON.stringify(id)} as its backup id, which is not a plain name, ` +
|
|
23
|
+
'so telstore cannot tell which files beside it are its chunks.',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readManifest(manifestPath) {
|
|
29
|
+
let bytes
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
bytes = await fs.readFile(manifestPath)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new Error(`Cannot read the manifest ${manifestPath}: ${err.message}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parseManifest(bytes)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every chunk is checked for presence and length before a byte is written, and every fault
|
|
41
|
+
// is named in one message: a download by hand that missed three files should cost one more
|
|
42
|
+
// trip to the chat, not three.
|
|
43
|
+
async function checkChunkFiles(manifest, files, dir) {
|
|
44
|
+
const missing = []
|
|
45
|
+
const wrong = []
|
|
46
|
+
|
|
47
|
+
for (const [index, chunk] of manifest.chunks.entries()) {
|
|
48
|
+
const name = path.basename(files[index])
|
|
49
|
+
let stat
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
stat = await fs.stat(files[index])
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err.code !== 'ENOENT') throw err
|
|
55
|
+
missing.push(name)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!stat.isFile()) {
|
|
60
|
+
wrong.push(`${name} is not a file.`)
|
|
61
|
+
} else if (stat.size !== chunk.size) {
|
|
62
|
+
wrong.push(`${name} has ${stat.size} bytes, the manifest records ${chunk.size} bytes.`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (missing.length === 0 && wrong.length === 0) return
|
|
67
|
+
|
|
68
|
+
const lines = [`Cannot join ${manifest.id}:`]
|
|
69
|
+
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
lines.push(`${missing.length} of ${manifest.chunks.length} chunks are not in ${dir}:`)
|
|
72
|
+
lines.push(...missing.map((name) => ` ${name}`))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(...wrong)
|
|
76
|
+
lines.push(
|
|
77
|
+
'Download them from the chat into that folder under exactly these names and run join ' +
|
|
78
|
+
'again. A browser that saved a name twice may have added " (1)" to it.',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
throw new Error(lines.join('\n'))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function writeAll(handle, buffer, length, position) {
|
|
85
|
+
let written = 0
|
|
86
|
+
|
|
87
|
+
while (written < length) {
|
|
88
|
+
const { bytesWritten } = await handle.write(buffer, written, length - written, position + written)
|
|
89
|
+
written += bytesWritten
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Copies one chunk file into place and hashes exactly the bytes it copied. The length was
|
|
94
|
+
// checked up front, and is checked again here because the file is read now, not then: a
|
|
95
|
+
// download still being written, or replaced in between, is a different file from the one
|
|
96
|
+
// that was stat'd.
|
|
97
|
+
//
|
|
98
|
+
// For an encrypted backup the file holds ciphertext: that is what is hashed against the
|
|
99
|
+
// manifest, and the plaintext written into place is hashed against the seal.
|
|
100
|
+
async function copyChunk({ file, handle, offset, chunk, count, buffer, advance, cipher = null, plainSha256 = null }) {
|
|
101
|
+
const name = path.basename(file)
|
|
102
|
+
const source = await fs.open(file, 'r')
|
|
103
|
+
const hash = createHash('sha256')
|
|
104
|
+
const plain = cipher ? createHash('sha256') : null
|
|
105
|
+
let copied = 0
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const { bytesRead } = await source.read(buffer, 0, buffer.length, copied)
|
|
110
|
+
|
|
111
|
+
if (bytesRead === 0) break
|
|
112
|
+
|
|
113
|
+
if (copied + bytesRead > chunk.size) {
|
|
114
|
+
throw new Error(`${name} grew past the ${chunk.size} bytes the manifest records while it was being read.`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const read = buffer.subarray(0, bytesRead)
|
|
118
|
+
const out = cipher ? cipher.apply(read, copied) : read
|
|
119
|
+
|
|
120
|
+
hash.update(read)
|
|
121
|
+
plain?.update(out)
|
|
122
|
+
await writeAll(handle, out, bytesRead, offset + copied)
|
|
123
|
+
copied += bytesRead
|
|
124
|
+
advance(bytesRead)
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
await source.close()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (copied !== chunk.size) {
|
|
131
|
+
throw new Error(`${name} has ${copied} bytes, the manifest records ${chunk.size} bytes.`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (hash.digest('hex') !== chunk.sha256) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${name} does not match the sha256 the manifest records for chunk ${chunk.i + 1}/${count}. ` +
|
|
137
|
+
'It is damaged or belongs to another backup — download it again.',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (plain && plain.digest('hex') !== plainSha256) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${name} matched its encrypted sha256 but decrypted to bytes that do not match the ` +
|
|
144
|
+
`manifest for chunk ${chunk.i + 1}/${count}. That points at telstore rather than at the download.`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The offline half of restore: the chunks and the manifest were downloaded by hand, from
|
|
150
|
+
// Telegram web or anywhere else, and only the reassembly is left. Nothing here talks to
|
|
151
|
+
// the network, so nothing here needs a login or a message id — but every guarantee restore
|
|
152
|
+
// makes about the bytes still holds, because the manifest is what makes them, not the chat.
|
|
153
|
+
export async function runJoin(manifestPath, options = {}, deps = {}) {
|
|
154
|
+
const {
|
|
155
|
+
confirm = askConfirm,
|
|
156
|
+
cwd = process.cwd(),
|
|
157
|
+
writeErr = (line) => process.stderr.write(line),
|
|
158
|
+
log: writeLog = (line) => console.log(line),
|
|
159
|
+
silent = false,
|
|
160
|
+
onTempChunk = () => {},
|
|
161
|
+
askPassword = realAskPassword,
|
|
162
|
+
interactive = () => Boolean(process.stdin.isTTY),
|
|
163
|
+
} = deps
|
|
164
|
+
|
|
165
|
+
const log = silent ? () => {} : writeLog
|
|
166
|
+
const warn = silent ? () => {} : writeErr
|
|
167
|
+
|
|
168
|
+
const manifest = await readManifest(manifestPath)
|
|
169
|
+
assertPlainId(manifest.id)
|
|
170
|
+
|
|
171
|
+
const dir = path.dirname(path.resolve(cwd, manifestPath))
|
|
172
|
+
const files = manifest.chunks.map((chunk) => path.join(dir, chunkFileName(manifest.id, chunk.i)))
|
|
173
|
+
|
|
174
|
+
await checkChunkFiles(manifest, files, dir)
|
|
175
|
+
|
|
176
|
+
// After the files are known to be there — that costs nothing and needs no password — and
|
|
177
|
+
// before the overwrite question, for restore's reason: nobody should answer [y/N] about a
|
|
178
|
+
// file telstore then cannot decrypt.
|
|
179
|
+
const opened = isEncrypted(manifest)
|
|
180
|
+
? await unlockManifest(manifest, { askPassword, interactive, say: log })
|
|
181
|
+
: null
|
|
182
|
+
|
|
183
|
+
const target = path.resolve(cwd, options.out ?? safeOutName(manifest.name))
|
|
184
|
+
|
|
185
|
+
// Not `.partial`: that name belongs to restore, which resumes from it, and a join aimed at
|
|
186
|
+
// the same file would otherwise truncate hours of somebody's download without asking.
|
|
187
|
+
const joining = `${target}.joining`
|
|
188
|
+
|
|
189
|
+
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence would have
|
|
190
|
+
// telstore overwrite the user's file without asking.
|
|
191
|
+
let exists = true
|
|
192
|
+
try {
|
|
193
|
+
await fs.stat(target)
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err.code !== 'ENOENT') throw err
|
|
196
|
+
exists = false
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (exists && !(await confirm(`${target} already exists. Overwrite? [y/N] `))) {
|
|
200
|
+
throw new Error('Cancelled on request.')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
log(`Backup ${manifest.id}`)
|
|
204
|
+
if (opened) log('Lock encrypted')
|
|
205
|
+
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)`)
|
|
206
|
+
log(`From ${dir}\n`)
|
|
207
|
+
|
|
208
|
+
// Said before the open, so a Ctrl-C that lands between the two still has the name to remove.
|
|
209
|
+
onTempChunk(joining)
|
|
210
|
+
|
|
211
|
+
let handle = null
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
handle = await fs.open(joining, 'w')
|
|
215
|
+
|
|
216
|
+
const buffer = Buffer.allocUnsafe(COPY_SIZE)
|
|
217
|
+
const count = manifest.chunks.length
|
|
218
|
+
const progress = createProgress({ total: manifest.size, label: `Chunk 1/${count}`, write: warn })
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
for (const [index, chunk] of manifest.chunks.entries()) {
|
|
222
|
+
progress.setLabel(`Chunk ${chunk.i + 1}/${count}`)
|
|
223
|
+
|
|
224
|
+
await copyChunk({
|
|
225
|
+
file: files[index],
|
|
226
|
+
handle,
|
|
227
|
+
offset: chunk.i * manifest.chunkSize,
|
|
228
|
+
chunk,
|
|
229
|
+
count,
|
|
230
|
+
buffer,
|
|
231
|
+
advance: progress.advance,
|
|
232
|
+
cipher: opened ? chunkCipher(opened.keys.chunkKey, chunk.iv) : null,
|
|
233
|
+
plainSha256: opened ? opened.plainSha256[chunk.i] : null,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
progress.finish()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await handle.close()
|
|
241
|
+
handle = null
|
|
242
|
+
|
|
243
|
+
// Last line of defence, as in restore: every chunk matched and the file is still the
|
|
244
|
+
// wrong length means the layout went wrong somewhere, and the rename must not happen.
|
|
245
|
+
const written = await fs.stat(joining)
|
|
246
|
+
|
|
247
|
+
if (written.size !== manifest.size) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`The joined file has ${written.size} bytes, the manifest records ${manifest.size} bytes — mismatch.`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await fs.rename(joining, target)
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (handle) await handle.close().catch(() => {})
|
|
256
|
+
|
|
257
|
+
// Unlike restore's .partial there is nothing here worth keeping: the chunks are still on
|
|
258
|
+
// disk, and a half-joined file is only somewhere a wrong file could be picked up from.
|
|
259
|
+
try {
|
|
260
|
+
await fs.rm(joining, { force: true })
|
|
261
|
+
} catch (rmErr) {
|
|
262
|
+
warn(`\nCould not remove ${joining} (${rmErr.message}). It is incomplete — remove it by hand.\n`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
throw err
|
|
266
|
+
} finally {
|
|
267
|
+
onTempChunk(null)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
271
|
+
|
|
272
|
+
return { path: target, size: manifest.size }
|
|
273
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseManifestCaption } from '../caption.js'
|
|
1
|
+
import { parseManifestCaption, terminalSafe } from '../caption.js'
|
|
2
2
|
import { chatName, describeChat } from '../chat.js'
|
|
3
3
|
import {
|
|
4
4
|
closeQuietly,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '../client.js'
|
|
9
9
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
10
10
|
import { MANIFEST_SUFFIX } from '../manifest.js'
|
|
11
|
+
import { createWalkNotice } from '../progress.js'
|
|
11
12
|
import { assertLoggedIn } from '../session.js'
|
|
12
13
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
13
14
|
|
|
@@ -21,6 +22,7 @@ const COLUMNS = [
|
|
|
21
22
|
{ header: 'CHUNKS', key: 'chunks', right: true },
|
|
22
23
|
{ header: 'CREATED', key: 'created' },
|
|
23
24
|
{ header: 'NOTE', key: 'note' },
|
|
25
|
+
{ header: 'LOCK', key: 'lock' },
|
|
24
26
|
]
|
|
25
27
|
|
|
26
28
|
// The table is read at a glance, and the note is the one field with no shape at all — 500
|
|
@@ -96,6 +98,7 @@ function toRow(message) {
|
|
|
96
98
|
chunks: UNKNOWN,
|
|
97
99
|
created: utcDay(message.date),
|
|
98
100
|
note: UNKNOWN,
|
|
101
|
+
lock: UNKNOWN,
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
@@ -106,6 +109,9 @@ function toRow(message) {
|
|
|
106
109
|
chunks: String(card.chunks),
|
|
107
110
|
created: card.createdAt.slice(0, 10),
|
|
108
111
|
note: card.note ? shorten(card.note) : UNKNOWN,
|
|
112
|
+
// The hint is here because this is where someone who forgot a password looks first. It comes
|
|
113
|
+
// off a caption anyone in the chat can edit, so it is made safe to print before it is printed.
|
|
114
|
+
lock: card.encrypted ? (card.hint ? `🔒 ${shorten(terminalSafe(card.hint))}` : '🔒') : UNKNOWN,
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
|
|
@@ -148,42 +154,13 @@ function matchesTerm(message, term) {
|
|
|
148
154
|
return searchableFields(message).some((field) => field.toLowerCase().includes(term))
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
// A walk of one page is over in about the time it takes to notice — 165ms against a real
|
|
152
|
-
// chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
|
|
153
|
-
// appears and is wiped in the same breath is a flicker, not information. Past that the read
|
|
154
|
-
// is long enough that silence reads as the hang this project refuses everywhere else.
|
|
155
|
-
//
|
|
156
|
-
// \r only moves the cursor home, so every line is padded to the widest one drawn and the last
|
|
157
|
-
// write wipes the row: the table that follows must never land on half a progress line.
|
|
158
|
-
const NOTICE_QUIET_MS = 400
|
|
159
|
-
const NOTICE_INTERVAL_MS = 200
|
|
160
|
-
|
|
161
|
-
function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
|
|
162
|
-
const startedAt = now()
|
|
163
|
-
let lastDrawnAt = 0
|
|
164
|
-
let widest = 0
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
tick(text) {
|
|
168
|
-
if (now() - startedAt < quietMs) return
|
|
169
|
-
if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
|
|
170
|
-
|
|
171
|
-
lastDrawnAt = now()
|
|
172
|
-
widest = Math.max(widest, text.length)
|
|
173
|
-
write(`\r${text.padEnd(widest)}`)
|
|
174
|
-
},
|
|
175
|
-
clear() {
|
|
176
|
-
if (widest === 0) return
|
|
177
|
-
write(`\r${' '.repeat(widest)}\r`)
|
|
178
|
-
},
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
157
|
function renderTable(rows) {
|
|
183
|
-
// Most people never write a note, and a column of dashes tells them nothing they
|
|
184
|
-
// already know
|
|
158
|
+
// Most people never write a note or encrypt, and a column of dashes tells them nothing they
|
|
159
|
+
// did not already know.
|
|
185
160
|
const columns = COLUMNS.filter(
|
|
186
|
-
(column) =>
|
|
161
|
+
(column) =>
|
|
162
|
+
(column.key !== 'note' || rows.some((row) => row.note !== UNKNOWN)) &&
|
|
163
|
+
(column.key !== 'lock' || rows.some((row) => row.lock !== UNKNOWN)),
|
|
187
164
|
)
|
|
188
165
|
|
|
189
166
|
const widths = columns.map((column) =>
|