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.
@@ -0,0 +1,434 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { describeChat } from '../chat.js'
5
+ import {
6
+ closeQuietly,
7
+ connect as realConnect,
8
+ findManifestMessage,
9
+ readMessageBytes as realReadMessageBytes,
10
+ } from '../client.js'
11
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
12
+ import { chunkCipher, decryptInPlace } from '../cipher.js'
13
+ import { isEncrypted, parseManifest } from '../manifest.js'
14
+ import { askPassword as realAskPassword, unlockManifest } from '../password.js'
15
+ import { createProgress, formatBytes, plural } from '../progress.js'
16
+ import { assertLoggedIn } from '../session.js'
17
+ import { requireChat, resolveSettings } from '../settings.js'
18
+ import { shellArg } from '../shell.js'
19
+ import { spawnProducer } from '../spawn.js'
20
+ import { tempDirFor } from '../state.js'
21
+ import { DestinationGoneError, discardChunkFile, writeChunkTo } from '../stream.js'
22
+ import { isGzipName } from '../tar.js'
23
+ import { createOnRetry, realDownloadChunk, realGetMessage } from './restore.js'
24
+
25
+ // `telstore restore <id> -- tar xzf -`: the backup's bytes go to a command's stdin instead of
26
+ // into a file.
27
+ //
28
+ // This is runRestore with the file taken away, and the file was carrying more than the bytes:
29
+ // no .partial, no resume record, no scan of what an earlier run left, no rename, and no stat
30
+ // at the end to compare against the manifest. What replaces all of it is the order of two
31
+ // things — verify the chunk, then hand it over — and the command's exit code.
32
+ export async function runRestoreStream(backupId, childArgv, options = {}, deps = {}) {
33
+ const {
34
+ connect = realConnect,
35
+ disconnect = (client) => client.destroy(),
36
+ configDir = defaultConfigDir(),
37
+ searchManifest = findManifestMessage,
38
+ readMessageBytes = realReadMessageBytes,
39
+ getMessage = realGetMessage,
40
+ downloadChunk = realDownloadChunk,
41
+ spawn = spawnProducer,
42
+ retryOptions = {},
43
+ writeErr = (line) => process.stderr.write(line),
44
+ log: writeLog = (line) => console.log(line),
45
+ silent = false,
46
+ onBackupId = () => {},
47
+ onTempChunk = () => {},
48
+ // How a Ctrl-C that will not wait still stops the command. Unlike the upload direction
49
+ // there is nothing in the chat to unwind, so this run does not ask to be waited for — but
50
+ // leaving without stopping tar lets it go on writing files after telstore is gone.
51
+ onChild = () => {},
52
+ // tarx only. The alias promises gzip, so it checks the claim before spending a gigabyte
53
+ // finding out; `restore <id> -- tar xf -` promises nothing and is asked nothing.
54
+ requireGzipName = false,
55
+ askPassword = realAskPassword,
56
+ interactive = () => Boolean(process.stdin.isTTY),
57
+ } = deps
58
+
59
+ const config = await loadConfig(configDir)
60
+
61
+ assertLoggedIn(config)
62
+
63
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
64
+ const chat = requireChat(settings)
65
+ const log = silent ? () => {} : writeLog
66
+ const warn = silent ? () => {} : writeErr
67
+ const onRetry = createOnRetry(warn)
68
+
69
+ onBackupId(backupId)
70
+
71
+ const client = await connect(config, { verbose: settings.verbose })
72
+ let child = null
73
+ let ended = false
74
+ let written = 0
75
+
76
+ // Bytes written into the command, counted as they go rather than added up from the sizes the
77
+ // manifest claims: what the end of this function compares against manifest.size is then a
78
+ // measurement of what went into the pipe rather than a restatement of what was expected to.
79
+ // It is also the only way a run that stopped in the middle of a chunk can say how much it had
80
+ // written, which is in the message it fails with — and it is written, never read: whether the
81
+ // command on the far end took those bytes off the pipe is what its exit code answers.
82
+ const handed = (bytes) => {
83
+ written += bytes
84
+ }
85
+
86
+ try {
87
+ const manifestMessage = await searchManifest(client, chat, backupId)
88
+
89
+ if (!manifestMessage) {
90
+ throw new Error(
91
+ `No manifest found for ${backupId} in ${chat}. ` +
92
+ 'Check the backup id, or use --chat to point at the right chat.',
93
+ )
94
+ }
95
+
96
+ // The whole-backup arithmetic is in here and stays there: parseManifest adds the chunk
97
+ // sizes up and compares them with manifest.size, refuses a layout where any chunk is not
98
+ // where restore would look for it, and counts the list against the size. A manifest that
99
+ // disagrees with itself therefore never reaches the line below, which is why this command
100
+ // does not add the sum again — two copies of one piece of arithmetic is how they start
101
+ // disagreeing, and the copy that is never reached is the one that would be wrong.
102
+ const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
103
+
104
+ // Before the download, which is the only reason this check is worth making at all: tar
105
+ // would say "not in gzip format" by itself, but only after every byte had arrived.
106
+ if (requireGzipName && !isGzipName(manifest.name)) {
107
+ throw new Error(
108
+ `${backupId} is called ${manifest.name}, which does not claim to be gzipped, and tarx ` +
109
+ 'always extracts with "tar xzf -". Run ' +
110
+ `"npx telstore restore ${shellArg(backupId)} -- tar xf -" if it is a plain tar ` +
111
+ 'archive. Refused now rather than after the whole backup has been downloaded, which ' +
112
+ 'is when tar would find out.',
113
+ )
114
+ }
115
+
116
+ // Before the command is started, for the reason the gzip check above runs before the
117
+ // download: a restore that cannot be decrypted should cost nothing, and a command started
118
+ // for it would be a tar waiting on a pipe that is never going to carry anything.
119
+ const opened = isEncrypted(manifest)
120
+ ? await unlockManifest(manifest, { askPassword, interactive, say: log })
121
+ : null
122
+
123
+ log(`Backup ${backupId}`)
124
+ log(`Name ${manifest.name} (${plural(manifest.chunks.length, 'chunk')}, ${formatBytes(manifest.size)})`)
125
+ if (opened) log('Lock encrypted')
126
+ log(`From ${describeChat(chat)}`)
127
+ log(`Into ${childArgv.join(' ')}\n`)
128
+
129
+ const tmp = tempDirFor(configDir)
130
+ await fs.mkdir(tmp, { recursive: true })
131
+
132
+ // Before the first download. A command that cannot start costs nothing here and a whole
133
+ // chunk if it is started later.
134
+ child = spawn(childArgv, { stdio: ['pipe', 'inherit', 'inherit'] })
135
+ onChild(child.kill)
136
+
137
+ // Raced against every step below rather than checked between them: a command that dies
138
+ // two minutes into an 1800MB download leaves that download with nowhere to go, and
139
+ // finishing it first would be eight minutes spent on bytes nobody will read. Always
140
+ // throws, so it can never be the value a race resolves with. The no-op catch is for the
141
+ // window before the first race attaches a handler, exactly as `spawnProducer` does it.
142
+ const gone = child.exited.then(
143
+ ({ code, signal }) => {
144
+ throw stoppedReading(childArgv, written, manifest.size, { code, signal })
145
+ },
146
+ (err) => {
147
+ throw err
148
+ },
149
+ )
150
+ gone.catch(() => {})
151
+
152
+ // An 'error' event with nothing listening for it is an uncaught exception, and stdin's own
153
+ // failures do not all arrive inside a window `writeChunkTo` is watching: `write()` returning
154
+ // true means the pipe took the bytes into its buffer, so the EPIPE belonging to the last
155
+ // write of a chunk can surface after that call has returned and its listeners have come off.
156
+ // This used to be absorbed by accident — the `pipeline` `writeChunkTo` was built on left its
157
+ // handlers on this stream for the life of the run — and absorbing it on purpose is not
158
+ // enough either: a pipe that failed means the command did not get what was written into it,
159
+ // which is the difference between a restore and a plausible-looking one. So it is latched,
160
+ // and read in two places: before every chunk after the first, and once the child's exit is
161
+ // in hand.
162
+ let pipeFailure = null
163
+ child.stdin.on('error', (err) => {
164
+ pipeFailure ??= err
165
+ })
166
+
167
+ const progress = createProgress({
168
+ total: manifest.size,
169
+ label: `Chunk 1/${manifest.chunks.length}`,
170
+ write: warn,
171
+ })
172
+
173
+ try {
174
+ for (const chunk of manifest.chunks) {
175
+ // Asked before the next chunk rather than only at the end, because by then the run has
176
+ // known for a whole download: a pipe that has gone will not take this chunk either, and
177
+ // fetching 1800MB to push into it is eight minutes spent on bytes nobody will read. It is
178
+ // also the only thing that ends a run whose command closed its stdin at a chunk boundary
179
+ // and did not exit — `gone` never settles for a command that is still running, and
180
+ // nothing here puts a deadline on waiting for one.
181
+ //
182
+ // `pipeFailure` alone is not enough here: it is latched from an 'error' event, and a
183
+ // command that closes its end of the pipe quietly between two chunks — without ever
184
+ // writing into it again to provoke one — leaves `pipeFailure` null. `writeChunkTo`'s own
185
+ // probe would still catch that, but only once it is pumping — after this chunk has
186
+ // already been downloaded for nothing. Checking the flags directly is what the probe
187
+ // itself checks, asked a chunk earlier.
188
+ if (pipeFailure !== null || child.stdin.destroyed || child.stdin.writableEnded) {
189
+ // A real 'error' means some of what telstore already wrote may never have arrived.
190
+ // A destroyed-or-ended pipe with no error behind it means the opposite: everything
191
+ // written so far was taken cleanly, and the command simply stopped reading before the
192
+ // backup ended — the same ending `stoppedReading` already says correctly.
193
+ throw pipeFailure !== null
194
+ ? pipeFailed(childArgv, written, pipeFailure)
195
+ : stoppedReading(childArgv, written, manifest.size)
196
+ }
197
+
198
+ const file = path.join(tmp, `${backupId}-${chunk.i}.chunk`)
199
+
200
+ // Said before the open, for the reason the upload direction says it before its own:
201
+ // the handler has to hold the name for the whole window in which the file can exist.
202
+ onTempChunk(file)
203
+
204
+ const handle = await fs.open(file, 'w+')
205
+
206
+ try {
207
+ progress.setLabel(`Chunk ${chunk.i + 1}/${manifest.chunks.length}`)
208
+
209
+ // getMessage and downloadChunk go over the network, and either can reject on its own
210
+ // — a stall timeout, a FLOOD_WAIT that outlived its retries, any teleproto error —
211
+ // which is a different ending from `gone` losing the race: `gone` only ever fires
212
+ // once the child has already exited, and its own throw already says so. A rejection
213
+ // from the network call itself used to propagate raw, saying nothing about the
214
+ // prefix already handed to the command — the same omission `networkFailed` closes for
215
+ // both calls, through the one `received()` wording rather than a fifth variant of it.
216
+ const message = await Promise.race([
217
+ getMessage(client, chat, chunk.msgId).catch((err) => {
218
+ throw networkFailed(err, childArgv, written)
219
+ }),
220
+ gone,
221
+ ])
222
+
223
+ if (!message) {
224
+ throw new Error(
225
+ `Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} ` +
226
+ `is no longer in ${chat}. This backup cannot be restored. ` +
227
+ `${received(childArgv, written)}`,
228
+ )
229
+ }
230
+
231
+ const { sha256, size } = await Promise.race([
232
+ downloadChunk(client, message, handle, 0, progress.advance, {
233
+ retryOptions: { ...retryOptions, onRetry },
234
+ concurrency: settings.downloadConcurrency,
235
+ }).catch((err) => {
236
+ throw networkFailed(err, childArgv, written)
237
+ }),
238
+ gone,
239
+ ])
240
+
241
+ if (size !== chunk.size) {
242
+ throw new Error(
243
+ `Chunk ${chunk.i + 1} arrived with ${size} bytes and the manifest records ` +
244
+ `${chunk.size} — mismatch. ${received(childArgv, written)}`,
245
+ )
246
+ }
247
+
248
+ if (sha256 !== chunk.sha256) {
249
+ throw new Error(
250
+ `Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. ` +
251
+ `${received(childArgv, written)}`,
252
+ )
253
+ }
254
+
255
+ // The rule this file keeps — no byte reaches the command before its chunk is verified —
256
+ // now means verified as plaintext: decrypted in the temp file, hashed against the seal,
257
+ // and only then pumped.
258
+ if (opened) {
259
+ const clear = await decryptInPlace(handle, 0, size, chunkCipher(opened.keys.chunkKey, chunk.iv))
260
+
261
+ if (clear !== opened.plainSha256[chunk.i]) {
262
+ throw new Error(
263
+ `Chunk ${chunk.i + 1} matched its encrypted sha256 but decrypted to bytes that do ` +
264
+ 'not match the manifest. That points at telstore rather than at the backup. ' +
265
+ `${received(childArgv, written)}`,
266
+ )
267
+ }
268
+ }
269
+
270
+ // Only now, and this line is the guarantee: everything above it is what makes the
271
+ // difference between handing a command the backup and handing it whatever arrived.
272
+ //
273
+ // The write's own failure is worded here rather than reported as it arrived, because
274
+ // a dead pipe is not a sentence anybody can act on: "write EPIPE" names the symptom,
275
+ // and what happened is what `stoppedReading` says — minus the exit code, which this
276
+ // path has not got. Usually it is not needed: measured on node 22, a command that
277
+ // dies mid-chunk loses this race to `gone`, which is one microtask behind the child's
278
+ // exit while the write has a read to finish and a latch to notice. What is left for
279
+ // this catch is the ending where no exit status is coming at all — a command that
280
+ // closes its end of the pipe and goes on working, `head -c 10` inside a shell that
281
+ // has more to do — and even there the write is only a mechanism that *can* report it,
282
+ // not one that always does: once `head` has read its ten bytes and stopped, a
283
+ // remainder small enough to sit entirely in the OS pipe buffer is accepted by
284
+ // `write()` without complaint — the kernel took it, nobody will ever read it, and this
285
+ // catch never fires. Measured 2026-09-10: a 19.8KB remainder behind that same `head -c
286
+ // 10` reports a restore that never happened; a 391KB one is refused correctly.
287
+ // `docs/design/data-integrity.md` has both numbers and what binds the guarantee.
288
+ //
289
+ // Which failure this was is asked of writeChunkTo, which says so by type:
290
+ // DestinationGoneError is the far end and nothing else is. Asking the pipe instead —
291
+ // `child.stdin.destroyed` — would have been one inference too many: a read error on
292
+ // the chunk file can leave the same trace, and telstore would then blame tar for a
293
+ // fault of its own. A code on the error is no better, because a far end that has gone
294
+ // reports itself as EPIPE, as ECONNRESET or as a premature close depending on when
295
+ // it went, and a list of spellings is how such a check quietly stops matching.
296
+ const pumping = writeChunkTo(child.stdin, handle, size, { onProgress: handed }).catch(
297
+ (err) => {
298
+ if (err instanceof DestinationGoneError) {
299
+ throw stoppedReading(childArgv, written, manifest.size)
300
+ }
301
+
302
+ throw err
303
+ },
304
+ )
305
+
306
+ await Promise.race([pumping, gone])
307
+ } finally {
308
+ await discardChunkFile(handle, file, { writeErr, chunkSize: manifest.chunkSize })
309
+
310
+ // Unsaid whether the removal worked or not: if it did there is nothing left to
311
+ // remove, and if it did not, discardChunkFile has already named the file on stderr.
312
+ onTempChunk(null)
313
+ }
314
+ }
315
+ } finally {
316
+ // Ended here rather than after the loop, so a failure mid-chunk still leaves the cursor
317
+ // on a fresh line and "Error: ..." does not land on top of the bar.
318
+ progress.finish()
319
+ }
320
+
321
+ // Unreachable if every chunk verified and every write moved the length it was given, which
322
+ // is why it is worth keeping: it is the one check that does not trust the ones above it,
323
+ // and it is counted from the bytes that went into the pipe rather than from the manifest.
324
+ if (written !== manifest.size) {
325
+ throw new Error(
326
+ `telstore wrote ${written} bytes into ${childArgv[0]} and the manifest records ` +
327
+ `${manifest.size}. Refusing to report a restore it cannot account for.`,
328
+ )
329
+ }
330
+
331
+ // The command has had every byte the manifest names; EOF is how it is told so.
332
+ child.stdin.end()
333
+
334
+ const { code, signal } = await child.exited
335
+ ended = true
336
+
337
+ // Before the exit code is read for what it says, because it cannot answer this: a command
338
+ // that exited 0 with the tail of the backup still sitting in a pipe it had stopped reading
339
+ // exited 0 all the same. The cost of checking is a restore refused in the one case where a
340
+ // command read every byte and left before telstore closed the pipe behind it; the cost of
341
+ // not checking is a backup reported as restored that the command never finished receiving,
342
+ // and this project pays the first to avoid the second.
343
+ if (pipeFailure !== null) {
344
+ throw pipeFailed(childArgv, written, pipeFailure, { code, signal })
345
+ }
346
+
347
+ if (code !== 0 || signal !== null) {
348
+ throw new Error(
349
+ signal !== null
350
+ ? `${childArgv[0]} was killed by ${signal} after receiving all ` +
351
+ `${formatBytes(written)}. telstore is not reporting a restore on its behalf.`
352
+ : `${childArgv[0]} exited ${code} after receiving all ${formatBytes(written)}. ` +
353
+ 'telstore is not reporting a restore on its behalf: every byte was correct and ' +
354
+ 'the command still did not finish.',
355
+ )
356
+ }
357
+
358
+ log(`\nDone. telstore wrote ${formatBytes(written)} into ${childArgv[0]}, which exited 0.`)
359
+
360
+ return { id: backupId, size: written, chunks: manifest.chunks.length }
361
+ } finally {
362
+ // A run that fell over mid-chunk leaves the command alive and blocked on a pipe nothing is
363
+ // going to write to again. Destroying the pipe is what turns that wait into something it
364
+ // can act on; the kill is for a command that is not waiting on stdin at all.
365
+ if (child && !ended) {
366
+ child.stdin.destroy()
367
+ child.kill()
368
+ }
369
+
370
+ onChild(null)
371
+
372
+ await closeQuietly(client, disconnect, (err) =>
373
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
374
+ )
375
+ }
376
+ }
377
+
378
+ // What the command got before this went wrong, in every message about a chunk that did not.
379
+ // A person deciding what to do next needs to know the difference between "it has half of my
380
+ // archive" and "it has nothing".
381
+ function received(childArgv, written) {
382
+ return written === 0
383
+ ? `${childArgv[0]} was given nothing.`
384
+ : `${childArgv[0]} had already been given ${formatBytes(written)}, which was correct but ` +
385
+ 'is not the whole backup — whatever it did with that is incomplete.'
386
+ }
387
+
388
+ // getMessage or downloadChunk rejecting is the same class of failure as the two chunk-mismatch
389
+ // branches above — the run stops mid-loop, not at a boundary the command already knows about —
390
+ // so it gets the same treatment rather than propagating whatever teleproto's error happened to
391
+ // say. `err.message` alone was measured reading "FAIL TIMEOUT: no response from Telegram after
392
+ // 3 attempts" while the command had already been handed a correct 195KB prefix and written it
393
+ // to disk; not one word about that in the raw error.
394
+ function networkFailed(err, childArgv, written) {
395
+ return new Error(`${err.message}. ${received(childArgv, written)}`)
396
+ }
397
+
398
+ // A pipe that failed, which is the one ending the command's own exit code cannot speak for, and
399
+ // one sentence for both the places that report it: the chunk boundary, where there is no exit
400
+ // status yet and may never be one, and after the child has exited.
401
+ function pipeFailed(childArgv, written, err, exit = null) {
402
+ const what =
403
+ exit === null
404
+ ? `${childArgv[0]}'s stdin failed`
405
+ : `${childArgv[0]} exited ${exit.signal !== null ? `on ${exit.signal}` : String(exit.code)}` +
406
+ ', but its stdin failed first'
407
+
408
+ return new Error(
409
+ `${what} (${err.message}) — so some of the ${formatBytes(written)} telstore wrote into it ` +
410
+ 'never arrived. Not reporting a restore on that. Nothing in the chat changed.',
411
+ )
412
+ }
413
+
414
+ // A command that is gone while there are bytes left. Exit 0 is included on purpose: `head -c
415
+ // 10` exits 0 having read ten bytes of a gigabyte, and reporting that as a restore would be
416
+ // the confident wrong answer. Measured, not assumed — see the probe table in the spec.
417
+ //
418
+ // `exit` is null on the one ending where no exit status is coming: the command closed its end
419
+ // of the pipe and went on working. What happened is the same thing either way, so it is the
420
+ // same sentence, and only the clause naming the exit code is missing — waiting for one there
421
+ // would mean waiting without a deadline on a command that may never exit at all.
422
+ function stoppedReading(childArgv, written, total, exit = null) {
423
+ const how =
424
+ exit === null
425
+ ? 'stopped reading'
426
+ : exit.signal !== null
427
+ ? `was killed by ${exit.signal}`
428
+ : `exited ${exit.code}`
429
+
430
+ return new Error(
431
+ `${childArgv[0]} ${how} with ${formatBytes(written)} of ${formatBytes(total)} written into ` +
432
+ 'it, so it did not receive the backup. Nothing in the chat changed.',
433
+ )
434
+ }
@@ -11,8 +11,10 @@ import { askConfirm } from '../confirm.js'
11
11
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
12
12
  import { assertLoggedIn } from '../session.js'
13
13
  import { requireChat, resolveSettings } from '../settings.js'
14
+ import { chunkCipher, decryptInPlace } from '../cipher.js'
14
15
  import { downloadToFile, hashRange } from '../downloader.js'
15
- import { parseManifest } from '../manifest.js'
16
+ import { isEncrypted, parseManifest, safeOutName } from '../manifest.js'
17
+ import { askPassword as realAskPassword, unlockManifest } from '../password.js'
16
18
  import { createProgress, formatBytes, formatDuration } from '../progress.js'
17
19
  import { clearRestore, pruneRestores, restoreKey, saveRestore } from '../state.js'
18
20
 
@@ -25,7 +27,32 @@ const LONG_WAIT_MS = 60_000
25
27
  // by then the trouble has outlived two backoffs and is worth saying out loud.
26
28
  const ANNOUNCE_AFTER_ATTEMPT = 3
27
29
 
28
- async function realGetMessage(client, peer, msgId) {
30
+ // Lifted out of runRestore so the streaming restore uses this one rather than a second copy
31
+ // that drifts. A retry nobody is told about is indistinguishable from a hung transfer, because
32
+ // the progress bar simply stops moving while the wait runs.
33
+ export function createOnRetry(warn) {
34
+ return function onRetry(err, attempt, delayMs, elapsedMs = 0) {
35
+ if (delayMs > LONG_WAIT_MS) {
36
+ warn(
37
+ `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
38
+ `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
39
+ )
40
+ return
41
+ }
42
+
43
+ // The exception to staying quiet: an attempt that took a minute to fail spent that
44
+ // minute with the bar frozen, which is exactly what a hang looks like. Those are worth
45
+ // a line the first time, whatever the attempt number.
46
+ if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
47
+
48
+ warn(
49
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
50
+ `${formatDuration(delayMs / 1000)}.\n`,
51
+ )
52
+ }
53
+ }
54
+
55
+ export async function realGetMessage(client, peer, msgId) {
29
56
  const [message] = await client.getMessages(peer, { ids: [msgId] })
30
57
  return message ?? null
31
58
  }
@@ -34,29 +61,12 @@ export async function realDownloadChunk(client, message, handle, offset, onProgr
34
61
  return await downloadToFile(client, message, handle.fd, { offset, onProgress, ...options })
35
62
  }
36
63
 
37
- // manifest.name comes from data downloaded off Telegram — don't trust it when picking
38
- // a path ourselves. path.basename stops "../../x" but still returns "..", "." or "" for
39
- // a few pathological names: path.resolve('..') is the parent directory, so a multi-GB
40
- // .partial file would land outside the current directory and only blow up at rename.
41
- function safeOutName(name) {
42
- const base = path.basename(String(name ?? ''))
43
-
44
- if (base === '' || base === '.' || base === '..') {
45
- throw new Error(
46
- `The name in the manifest ("${name}") cannot be used as a file name. ` +
47
- 'Run again with --out <path> to choose where to write.',
48
- )
49
- }
50
-
51
- return base
52
- }
53
-
54
64
  // How many chunks at the front of a .partial already hold what the manifest says they
55
65
  // should. The evidence is the file, never a record: a record makes claims about a local
56
66
  // file anyone can edit between runs, and a claim that is wrong here renames a corrupt file
57
67
  // into place. Every chunk in the finished file was hashed against the manifest by the run
58
68
  // that renamed it, whether this run downloaded it or found it already there.
59
- async function scanPartial(handle, manifest, log) {
69
+ async function scanPartial(handle, manifest, log, expected) {
60
70
  let done = 0
61
71
 
62
72
  for (const chunk of manifest.chunks) {
@@ -65,7 +75,7 @@ async function scanPartial(handle, manifest, log) {
65
75
  // Downloads run in order, so what is already present is a prefix. The first chunk that
66
76
  // does not match is where this run starts, and reading past it would hash gigabytes
67
77
  // nobody has written yet.
68
- if (digest !== chunk.sha256) break
78
+ if (digest !== expected(chunk)) break
69
79
 
70
80
  done += 1
71
81
  log(`Chunk ${chunk.i + 1}/${manifest.chunks.length} already restored, skipping.`)
@@ -89,6 +99,10 @@ export async function runRestore(backupId, options = {}, deps = {}) {
89
99
  log: writeLog = (line) => console.log(line),
90
100
  silent = false,
91
101
  onBackupId = () => {},
102
+ askPassword = realAskPassword,
103
+ interactive = () => Boolean(process.stdin.isTTY),
104
+ // Passwords that opened an earlier backup in the same batch, tried before asking again.
105
+ knownPasswords = [],
92
106
  } = deps
93
107
 
94
108
  const config = await loadConfig(configDir)
@@ -100,25 +114,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
100
114
  // A restore keeps no progress file, so a part that comes back -503 is retried rather than
101
115
  // thrown away — and a retry nobody is told about is indistinguishable from a hung transfer,
102
116
  // because the progress bar simply stops moving while the wait runs.
103
- function onRetry(err, attempt, delayMs, elapsedMs = 0) {
104
- if (delayMs > LONG_WAIT_MS) {
105
- warn(
106
- `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
107
- `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
108
- )
109
- return
110
- }
111
-
112
- // The exception to staying quiet: an attempt that took a minute to fail spent that
113
- // minute with the bar frozen, which is exactly what a hang looks like. Those are worth
114
- // a line the first time, whatever the attempt number.
115
- if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
116
-
117
- warn(
118
- `\nTemporary error (${err.message}), retry ${attempt} in ` +
119
- `${formatDuration(delayMs / 1000)}.\n`,
120
- )
121
- }
117
+ const onRetry = createOnRetry(warn)
122
118
 
123
119
  const client = await connect(config, { verbose: settings.verbose })
124
120
 
@@ -133,6 +129,17 @@ export async function runRestore(backupId, options = {}, deps = {}) {
133
129
  }
134
130
 
135
131
  const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
132
+
133
+ // Before the overwrite question and before the .partial: a password that cannot be had must
134
+ // cost nothing, and nobody should answer [y/N] about a file telstore then cannot write.
135
+ const opened = isEncrypted(manifest)
136
+ ? await unlockManifest(manifest, { askPassword, interactive, known: knownPasswords, say: log })
137
+ : null
138
+
139
+ // What each finished chunk hashes to in the .partial. A decrypted chunk is plaintext there,
140
+ // and only the sealed hash says what that plaintext must be.
141
+ const onDisk = (chunk) => (opened ? opened.plainSha256[chunk.i] : chunk.sha256)
142
+
136
143
  // When the user passes --out, respect that path verbatim.
137
144
  const target = path.resolve(options.out ?? safeOutName(manifest.name))
138
145
  const partial = `${target}.partial`
@@ -187,6 +194,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
187
194
  }
188
195
 
189
196
  log(`Backup ${manifest.id}`)
197
+ if (opened) log('Lock encrypted')
190
198
  log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
191
199
 
192
200
  let handle
@@ -219,7 +227,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
219
227
  // that long is the hang this project refuses everywhere: the heading lands before
220
228
  // the first read and a line per chunk arrives as the scan advances.
221
229
  log(`Checking what is already in ${partial}...`)
222
- done = await scanPartial(handle, manifest, log)
230
+ done = await scanPartial(handle, manifest, log, onDisk)
223
231
  if (done === 0) log(`Nothing in ${partial} matches this backup, starting over.`)
224
232
  log('')
225
233
  }
@@ -295,6 +303,26 @@ export async function runRestore(backupId, options = {}, deps = {}) {
295
303
  )
296
304
  }
297
305
 
306
+ // Only after the ciphertext has matched: that match is what proves these are the bytes
307
+ // that went up, and the manifest's seal is what proves the hash itself. The plaintext
308
+ // check that follows is the second one, and it exists for telstore's own mistakes.
309
+ if (opened) {
310
+ const clear = await decryptInPlace(
311
+ handle,
312
+ chunk.i * manifest.chunkSize,
313
+ chunk.size,
314
+ chunkCipher(opened.keys.chunkKey, chunk.iv),
315
+ )
316
+
317
+ if (clear !== opened.plainSha256[chunk.i]) {
318
+ throw new Error(
319
+ `Chunk ${chunk.i + 1} matched its encrypted sha256 but decrypted to bytes that do ` +
320
+ 'not match the manifest. That points at telstore rather than at the backup; ' +
321
+ `nothing was renamed, and the download is kept at ${partial} for inspection.`,
322
+ )
323
+ }
324
+ }
325
+
298
326
  await note(chunk.i + 1)
299
327
  }
300
328
  } finally {
@@ -395,11 +423,15 @@ export async function runRestores(backupIds, options = {}, deps = {}) {
395
423
  const warn = silent ? () => {} : writeErr
396
424
 
397
425
  let shared = null
426
+ // Shared across every id in the batch, so a password that opened the first backup is tried
427
+ // silently on the rest before asking again.
428
+ const passwords = []
398
429
  const perId = {
399
430
  ...deps,
400
431
  connect: async (theirConfig, connectOptions) =>
401
432
  (shared ??= await connect(theirConfig, connectOptions)),
402
433
  disconnect: async () => {},
434
+ knownPasswords: passwords,
403
435
  }
404
436
 
405
437
  const results = []