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,507 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { MAX_CHUNKS, PART_SIZE } from '../chunking.js'
6
+ import { chunkCaption, manifestCaption, parseNote, terminalSafe } from '../caption.js'
7
+ import { chatName, describeChat } from '../chat.js'
8
+ import { chunkCipher, deriveKeys, newIv, newSalt, sealManifest } from '../cipher.js'
9
+ import {
10
+ MESSAGE_BATCH_SIZE,
11
+ closeQuietly,
12
+ connect as realConnect,
13
+ deleteMessages as realDeleteMessages,
14
+ } from '../client.js'
15
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
16
+ import {
17
+ buildManifest,
18
+ chunkFileName,
19
+ manifestFileName,
20
+ newBackupId,
21
+ serializeManifest,
22
+ } from '../manifest.js'
23
+ import { askNewPassword as realAskNewPassword } from '../password.js'
24
+ import { createStreamProgress, formatBytes, plural } from '../progress.js'
25
+ import { requireChat, resolveSettings } from '../settings.js'
26
+ import { deleteCommand } from '../shell.js'
27
+ import { spawnProducer } from '../spawn.js'
28
+ import {
29
+ MAX_STATES,
30
+ clearState,
31
+ markChunkDone,
32
+ pruneStates,
33
+ saveState,
34
+ streamKey,
35
+ tempDirFor,
36
+ } from '../state.js'
37
+ import { ChunkReader, discardChunkFile } from '../stream.js'
38
+ import { uploadRange } from '../uploader.js'
39
+ import { createOnRetry, realSendChunk, realSendManifest } from './upload.js'
40
+
41
+ // `telstore a.tar -- tar cf - ./a`: the backup's bytes are what the command writes, and the
42
+ // name is a label, not a file telstore reads.
43
+ //
44
+ // This is runUpload with the one thing it leans on taken away — a length known up front — so
45
+ // there is no planChunks, no resume, no re-stat, and no total on the bar. What replaces the
46
+ // re-stat is the child's exit code, which is the whole reason telstore spawns the command
47
+ // instead of reading a pipe.
48
+ export async function runStreamUpload(name, childArgv, options = {}, deps = {}) {
49
+ const {
50
+ connect = realConnect,
51
+ sendChunk = realSendChunk,
52
+ sendManifest = realSendManifest,
53
+ disconnect = (client) => client.destroy(),
54
+ configDir = defaultConfigDir(),
55
+ partSize = PART_SIZE,
56
+ // Through deps rather than read straight off the constant: this branch only fires after
57
+ // ten thousand chunks have gone out, and a limit a test cannot lower is a limit no test
58
+ // will ever reach.
59
+ maxChunks = MAX_CHUNKS,
60
+ spawn = spawnProducer,
61
+ deleteMessages = realDeleteMessages,
62
+ retryOptions = {},
63
+ writeErr = (line) => process.stderr.write(line),
64
+ log: writeLog = (line) => console.log(line),
65
+ silent = false,
66
+ onBackupId = () => {},
67
+ // How Ctrl-C reaches a run that must not be killed where it stands. See the call below.
68
+ onAbortable = () => {},
69
+ // Which chunk file this run is holding, so the one ending that does not come back through
70
+ // `discardChunkFile` can still take it with it. Said before the file is opened and unsaid
71
+ // after it is removed, so the caller's copy is never narrower than what is actually on disk.
72
+ onTempChunk = () => {},
73
+ askNewPassword = realAskNewPassword,
74
+ secret = null,
75
+ } = deps
76
+
77
+ // Before the command is started, let alone connected to Telegram: the note is the one thing
78
+ // this run sends that a person typed by hand, and it goes into the manifest, which goes out
79
+ // last. A note Telegram would refuse has to stop the run here, not after an hour of pg_dump.
80
+ const note = parseNote(options.note)
81
+
82
+ const config = await loadConfig(configDir)
83
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
84
+ const chat = requireChat(settings)
85
+ const chunkSize = settings.chunkSize
86
+ const concurrency = settings.uploadConcurrency
87
+
88
+ // Before the record, the connection and the command: a run that cannot get a password has
89
+ // nothing to unwind, and nobody should watch pg_dump start before being asked for one. No
90
+ // check value is kept — a stream is never resumed, so there is no second run to compare with.
91
+ let keys = null
92
+ let enc = null
93
+
94
+ if (options.encrypt) {
95
+ const chosen = secret ?? (await askNewPassword())
96
+ const salt = newSalt()
97
+
98
+ keys = await deriveKeys(chosen.password, salt)
99
+ enc = { salt, ...(chosen.hint ? { hint: chosen.hint } : {}) }
100
+ }
101
+
102
+ const id = newBackupId()
103
+ const key = streamKey(id)
104
+
105
+ // A stream record exists for one reason: to say what is already in the chat when the run
106
+ // fails, because nothing else will ever point at those chunks. There is no path, size or
107
+ // mtime to key it on and nothing to resume onto — `kind` is what tells status and delete so.
108
+ let state = { v: 1, kind: 'stream', id, chat: String(chat), name, chunkSize, done: {} }
109
+
110
+ await saveState(key, state, configDir)
111
+
112
+ // Every stream upload adds to the directory, so this is where it can grow. The report goes
113
+ // out even when the caller asked for silence: this is not narration about a transfer, it is
114
+ // telstore dropping the only record of someone else's chunks.
115
+ for (const gone of await pruneStates(configDir)) {
116
+ writeErr(
117
+ `\nDropped the record of unfinished backup ${gone.id}: telstore keeps the ` +
118
+ `${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
119
+ 'searchable by that id, but that backup can no longer be resumed.\n',
120
+ )
121
+ }
122
+
123
+ onBackupId(id)
124
+
125
+ // The producer and the reader are named here rather than where they are made, because the
126
+ // abort below has to be handed out before either exists.
127
+ let aborted = false
128
+ let child = null
129
+ let reader = null
130
+
131
+ // A run whose chunks are removed when it fails cannot be killed where it stands: exiting at
132
+ // the signal would leave in the chat exactly the chunks this command promises never to
133
+ // leave, and removing them is a network round trip per batch. So Ctrl-C asks the run to
134
+ // stop instead, through this, and waits for the rollback below to finish.
135
+ //
136
+ // Handed over before connect, not after the child is spawned: from the moment the record
137
+ // exists there is something a Ctrl-C has to unwind, and a caller that has not been given
138
+ // this yet has no choice but to exit on the spot.
139
+ onAbortable(async () => {
140
+ aborted = true
141
+
142
+ // The kill stops the producer; closing the reader is what unblocks a fill still waiting
143
+ // on a pipe the child is never going to write to again.
144
+ if (child) child.kill()
145
+ if (reader) await reader.close()
146
+ }, { chat })
147
+
148
+ // The one error the caller is meant to recognise: a run that stopped because someone asked
149
+ // it to has nothing to report that Ctrl-C did not already say. A rollback that could not
150
+ // finish throws a fresh error of its own instead, and that one is still a failure.
151
+ function stopped() {
152
+ const err = new Error('Stopped before the backup was finished.')
153
+ err.interrupted = true
154
+ return err
155
+ }
156
+
157
+ const log = silent ? () => {} : writeLog
158
+ const warn = silent ? () => {} : writeErr
159
+ const onRetry = createOnRetry(warn)
160
+
161
+ // Every message id this run has put in the chat, in the order it put them there. The record
162
+ // on disk is the durable copy, for the run that dies without getting this far; this one is
163
+ // what rollback reaches for, because it is right even when the write to disk is the thing
164
+ // that failed.
165
+ const sent = []
166
+ let client = null
167
+
168
+ // A file upload keeps its chunks on purpose — a second run resumes onto them. A stream
169
+ // cannot be resumed: the bytes have gone past, and the next run cuts them differently. So a
170
+ // chunk left in the chat by a failed stream is a chunk nothing will ever point at again,
171
+ // sitting in somebody's Telegram with no manifest naming it. Removing them is part of
172
+ // failing, not a courtesy. Always throws.
173
+ async function rollback(err) {
174
+ if (sent.length === 0) {
175
+ // Cleared even when it names nothing. An empty record is useless, but it still counts
176
+ // against MAX_STATES exactly as a full one does — so leaving it behind can evict the
177
+ // record of a real upload whose chunks are still in a chat, which is the loss
178
+ // pruneStates goes out of its way to announce.
179
+ await clearState(key, configDir)
180
+ throw err
181
+ }
182
+
183
+ // "message" rather than "chunk": the manifest joins this list on the narrow path where a
184
+ // run fails after sending it, and a count that says "3 chunks" for two chunks and a card
185
+ // is telstore describing the chat wrongly in the one report someone reads closely.
186
+ warn(`\nRemoving the ${plural(sent.length, 'message')} this run already sent...\n`)
187
+
188
+ const loud = sent.length > MESSAGE_BATCH_SIZE
189
+ let removed = 0
190
+
191
+ try {
192
+ await deleteMessages(client, chat, sent, {
193
+ retryOptions: { ...retryOptions, onRetry },
194
+ onBatch: (done, total) => {
195
+ removed = done
196
+ if (loud) warn(`\rRemoving messages ${done}/${total}…`)
197
+ },
198
+ })
199
+ } catch (cleanupErr) {
200
+ // The record stays, and deliberately: it is the only list of these message ids, since
201
+ // there is no manifest in the chat and there never will be one. `delete` reads exactly
202
+ // this record through findStates when it finds no manifest, which is why that is the
203
+ // command to name. The original failure is still said first — the rollback is what
204
+ // happened next, not what went wrong.
205
+ //
206
+ // The chat is always named, where `status` leaves --chat out of a *resume* when it
207
+ // matches the destination in force. status compares against the destination its own run
208
+ // resolved, which is the one the pasted command will resolve too. Here the destination
209
+ // in force may have come from a --chat on this command line, which the later `delete`
210
+ // will not carry: it would resolve its own chat from config and fire these ids at that
211
+ // peer instead, destroying whatever happens to carry them there. `deleteCommand` holds
212
+ // that rule for every place that prints this line; `chat` is required by the time a run
213
+ // has sent anything, so the chatless branch is not reachable from here.
214
+ throw new Error(
215
+ `${err.message}\n\ntelstore then removed ${removed} of the ` +
216
+ `${plural(sent.length, 'message')} it had sent before Telegram refused: ` +
217
+ `${cleanupErr.message}. The rest are still in ${chatName(chat)}. The local record ` +
218
+ 'was left in place on purpose — it is the only list of them on this machine. Run ' +
219
+ `"${deleteCommand(id, chat)}" to remove them.`,
220
+ )
221
+ }
222
+
223
+ if (loud) warn('\n')
224
+
225
+ await clearState(key, configDir)
226
+
227
+ throw err
228
+ }
229
+
230
+ log(`Backup ${id}`)
231
+ log(`Name ${name} (chunks of ${formatBytes(chunkSize)})`)
232
+ if (enc) log(`Lock encrypted${enc.hint ? ` (hint: ${terminalSafe(enc.hint)})` : ''}`)
233
+ log(`From ${childArgv.join(' ')}`)
234
+ log(`To ${describeChat(chat)}\n`)
235
+
236
+ try {
237
+ client = await connect(config, { verbose: settings.verbose })
238
+
239
+ // Connecting is the one stretch long enough for Ctrl-C to arrive before the command has
240
+ // been started, and starting someone's command after they asked telstore to stop is the
241
+ // one thing a wait must not turn into.
242
+ if (aborted) throw stopped()
243
+
244
+ const tmp = tempDirFor(configDir)
245
+ await fs.mkdir(tmp, { recursive: true })
246
+
247
+ child = spawn(childArgv)
248
+ reader = new ChunkReader(child.stdout)
249
+
250
+ let size = 0
251
+ let count = 0
252
+ let ended = false
253
+ let progress = null
254
+
255
+ try {
256
+ try {
257
+ for (;;) {
258
+ const file = path.join(tmp, `${id}-${count}.chunk`)
259
+
260
+ // Said before the open rather than after it, so the handler holds the name for the
261
+ // whole window in which a file can exist: 'w+' creates it, and a second Ctrl-C
262
+ // landing while the open is in flight would otherwise find nothing to remove.
263
+ // Unlinking a file that was never made is an ENOENT the caller ignores.
264
+ //
265
+ // What this does not cover, and it looks as though it should: an open that creates
266
+ // the file and then throws leaves the loop without entering the `try` below, so the
267
+ // `finally` that removes it never runs and nothing on that path reads the name back.
268
+ // `status` is what catches that one, with every other ending no handler sees.
269
+ onTempChunk(file)
270
+
271
+ const handle = await fs.open(file, 'w+')
272
+ let eof = false
273
+
274
+ try {
275
+ // Nothing pulls ahead of this call, which is what stops the child running away
276
+ // with the pipe while a chunk spends three minutes uploading: the backlog waits
277
+ // in the kernel's buffer and in the child, not in this process's memory.
278
+ const filled = await reader.fill(handle, chunkSize)
279
+ eof = filled.eof
280
+
281
+ // An abort that lands while this fill was waiting must not become one more chunk
282
+ // in the chat. The rollback below would remove it again, but not before minutes
283
+ // of uploading had gone by with the bar still moving, in front of the person who
284
+ // asked telstore to stop.
285
+ if (aborted) throw stopped()
286
+
287
+ if (filled.bytes > 0) {
288
+ // Asked of bytes that actually arrived, not of the count alone. A stream that
289
+ // ends exactly on a chunk boundary reports eof only on the fill after it, so a
290
+ // check before the fill would refuse a backup of exactly maxChunks chunks —
291
+ // one the file path builds happily.
292
+ //
293
+ // Checked here rather than before the first byte because there is no length to
294
+ // count chunks from, and a stream cannot be re-cut: the way out is a bigger
295
+ // chunk on the next run, not a resume of this one.
296
+ if (count >= maxChunks) {
297
+ throw new Error(
298
+ `This command has already produced ${maxChunks} chunks of ` +
299
+ `${formatBytes(chunkSize)} and has not finished, which is as many as a ` +
300
+ 'backup holds. Run again with a larger --chunk-size.',
301
+ )
302
+ }
303
+
304
+ const fileName = chunkFileName(id, count)
305
+
306
+ // No bar until there is something to draw one for. A command that writes
307
+ // nothing would otherwise get a bar springing into existence at zero, only to
308
+ // be told a moment later that there is no backup to make.
309
+ if (progress === null) {
310
+ progress = createStreamProgress({ label: `Chunk ${count + 1}`, write: warn })
311
+ } else {
312
+ progress.setLabel(`Chunk ${count + 1}`)
313
+ }
314
+
315
+ // A fresh iv per chunk; see newIv in src/cipher.js for why it is never derived
316
+ // from the index.
317
+ const iv = keys ? newIv() : null
318
+ const cipher = keys ? chunkCipher(keys.chunkKey, iv) : null
319
+ const plain = keys ? createHash('sha256') : null
320
+
321
+ // Offset 0 of a file holding exactly this chunk: uploadRange neither knows nor
322
+ // cares that the bytes arrived through a pipe rather than off a disk.
323
+ const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
324
+ offset: 0,
325
+ length: filled.bytes,
326
+ fileName,
327
+ concurrency,
328
+ partSize,
329
+ onProgress: (bytes) => progress.advance(bytes),
330
+ retryOptions: { ...retryOptions, onRetry },
331
+ transform: cipher
332
+ ? (bytes, at) => {
333
+ plain.update(bytes)
334
+ return cipher.apply(bytes, at)
335
+ }
336
+ : undefined,
337
+ })
338
+
339
+ const message = await sendChunk(client, chat, {
340
+ inputFile,
341
+ fileName,
342
+ // A stream knows the number and not the count. The manifest card carries the
343
+ // total once there finally is one.
344
+ caption: chunkCaption({ id, number: count + 1, total: null }),
345
+ })
346
+
347
+ // Before the record is written, not after: a chunk is in the chat the instant
348
+ // sendChunk returns, and a saveState that throws must not be what hides it
349
+ // from the rollback that is about to run.
350
+ sent.push(message.id)
351
+
352
+ // Recorded the moment it lands, because from here on this record is the only
353
+ // list of what is in the chat under this id.
354
+ state = await markChunkDone(
355
+ key,
356
+ state,
357
+ count,
358
+ {
359
+ msgId: message.id,
360
+ size: filled.bytes,
361
+ sha256,
362
+ ...(cipher ? { iv, plainSha256: plain.digest('hex') } : {}),
363
+ },
364
+ configDir,
365
+ )
366
+
367
+ size += filled.bytes
368
+ count += 1
369
+ }
370
+ } finally {
371
+ await discardChunkFile(handle, file, { writeErr, chunkSize })
372
+
373
+ // Unsaid whether the removal worked or not. If it did there is nothing left to
374
+ // remove; if it did not, `discardChunkFile` has already named the file on stderr,
375
+ // and a second attempt from the signal handler would say it twice.
376
+ onTempChunk(null)
377
+ }
378
+
379
+ if (eof) break
380
+ }
381
+ } finally {
382
+ // Same reason as runUpload: a send that fails must not leave "Error: ..." printed
383
+ // over the bar's own line.
384
+ progress?.finish()
385
+ }
386
+
387
+ // The stream's answer to the file path's re-stat, and the reason telstore spawns the
388
+ // command rather than reading a pipe. An EOF after a crash and an EOF after success are
389
+ // the same event on this end of the pipe; the exit code is the only thing that tells
390
+ // them apart, and a manifest sent without it would describe a truncated archive that
391
+ // restores perfectly and is garbage.
392
+ const { code, signal } = await child.exited
393
+ ended = true
394
+
395
+ if (code !== 0 || signal !== null) {
396
+ throw new Error(
397
+ signal !== null
398
+ ? `${childArgv[0]} was killed by ${signal} after writing ${formatBytes(size)}. ` +
399
+ 'telstore is not sending the manifest: what it wrote is an unfinished file.'
400
+ : `${childArgv[0]} exited ${code} after writing ${formatBytes(size)}. ` +
401
+ 'telstore is not sending the manifest: what it wrote is an unfinished file.',
402
+ )
403
+ }
404
+
405
+ // A clean exit having written nothing is usually a command whose arguments were wrong,
406
+ // and a backup of nothing is not a backup — restore would have nothing to write.
407
+ if (size === 0) {
408
+ throw new Error(`${childArgv[0]} wrote nothing, so there is no backup to make.`)
409
+ }
410
+
411
+ let manifest = buildManifest({
412
+ id,
413
+ name,
414
+ size,
415
+ chunkSize,
416
+ note,
417
+ enc,
418
+ chunks: Array.from({ length: count }, (_, i) => ({ i, ...state.done[String(i)] })),
419
+ })
420
+
421
+ if (keys) {
422
+ manifest = sealManifest(
423
+ manifest,
424
+ keys,
425
+ Array.from({ length: count }, (_, i) => state.done[String(i)].plainSha256),
426
+ )
427
+ }
428
+
429
+ const card = await sendManifest(client, chat, {
430
+ bytes: serializeManifest(manifest),
431
+ fileName: manifestFileName(id),
432
+ caption: manifestCaption({
433
+ id: manifest.id,
434
+ name: manifest.name,
435
+ size: manifest.size,
436
+ chunks: manifest.chunks.length,
437
+ createdAt: manifest.createdAt,
438
+ note: manifest.note ?? null,
439
+ encrypted: Boolean(keys),
440
+ hint: manifest.enc?.hint ?? null,
441
+ }),
442
+ })
443
+
444
+ // The manifest is a message this run put in the chat like any other. Anything that
445
+ // fails after this line — the record write below, the closing line written into a pipe
446
+ // that has gone away — still rolls back, and a rollback that took the chunks but left
447
+ // this would leave a backup `list` advertises and `restore` cannot fulfil. Pushed last
448
+ // so it is removed last, the order `delete` keeps for the same reason: the manifest is
449
+ // the only index of the ids under it, so it is the one thing worth having if a removal
450
+ // stops halfway.
451
+ sent.push(card.id)
452
+
453
+ // And into the record, before that record can be left behind by a rollback that could
454
+ // not finish. `sent` is this process's memory and dies with it; the record is what
455
+ // `delete` reads afterwards, and the only other way it could find this card is
456
+ // `findManifestMessage`, which asks Telegram's text index — docs/design/captions.md
457
+ // records that index returning nothing for a channel whose documents were all plainly
458
+ // there, and nothing predicts when it happens. A delete that cannot find the manifest
459
+ // takes the chunks and leaves the card behind advertising a backup restore cannot
460
+ // fulfil. Written after the push, for the same reason the chunk ids are: a saveState
461
+ // that throws must not be what hides this message from the rollback about to run.
462
+ state = { ...state, manifestMsgId: card.id }
463
+ await saveState(key, state, configDir)
464
+
465
+ log(`\nDone. Restore with:\n npx telstore restore ${id}`)
466
+
467
+ // Cleared after the closing line rather than before it, which is what gives the write
468
+ // above anything to protect. Writing that line is a real thing that fails — `telstore …
469
+ // | head` closes the pipe under telstore's feet — and it rolls the run back. A record
470
+ // cleared a moment earlier would leave a rollback that Telegram then refuses with the
471
+ // chunks and the card still in the chat and nothing on this machine naming any of them.
472
+ await clearState(key, configDir)
473
+
474
+ return { id, chunks: count, size }
475
+ } finally {
476
+ // A run that fell over mid-chunk leaves the producer alive and blocked writing into a
477
+ // pipe nobody is reading. Killing it is not rollback — it is closing the door this
478
+ // function opened, and the door is more than the process: the abandoned iterator still
479
+ // holds stdout, so a child that outlives the signal goes on waiting on a pipe whose
480
+ // reader is never coming back. Returning the iterator releases it and destroys the
481
+ // stream, which turns that wait into an EPIPE the child can actually act on.
482
+ if (!ended) {
483
+ child.kill()
484
+ await reader.close()
485
+ }
486
+ }
487
+ } catch (err) {
488
+ // Whatever an abort surfaced as — a fill rejecting on a destroyed pipe, a producer that
489
+ // died on the signal, a stall that never came back — the reason this run stopped is the
490
+ // Ctrl-C, and saying so is what lets the caller leave with 130 instead of reporting a
491
+ // failure nobody had.
492
+ //
493
+ // Always throws: the error itself once the chat is clean again, or a report of the
494
+ // rollback that could not finish.
495
+ await rollback(aborted ? stopped() : err)
496
+ } finally {
497
+ // connect is inside the try now, because a rollback needs a live client and the finally
498
+ // that closes one has to run after it. So a connect that failed leaves nothing to close,
499
+ // and a warning about closing a client that never existed would bury the real reason the
500
+ // run stopped.
501
+ if (client) {
502
+ await closeQuietly(client, disconnect, (err) =>
503
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
504
+ )
505
+ }
506
+ }
507
+ }