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
package/src/cipher.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, scrypt } from 'node:crypto'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const derive = promisify(scrypt)
|
|
5
|
+
|
|
6
|
+
// Pinned to manifest version 2 and never read from a manifest, for the reason src/token.js
|
|
7
|
+
// pins its own: a manifest naming its own N would let a stranger decide how much memory this
|
|
8
|
+
// machine allocates, and whoever holds a manifest can try passwords offline as fast as their
|
|
9
|
+
// hardware allows — 64MB per attempt is what makes that expensive.
|
|
10
|
+
const SCRYPT = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }
|
|
11
|
+
const KEY_BYTES = 32
|
|
12
|
+
const SALT_BYTES = 16
|
|
13
|
+
const IV_BYTES = 8
|
|
14
|
+
const NONCE_BYTES = 12
|
|
15
|
+
const TAG_BYTES = 16
|
|
16
|
+
const BLOCK = 16
|
|
17
|
+
const SHA256 = /^[0-9a-f]{64}$/
|
|
18
|
+
|
|
19
|
+
// Large enough that a 1800MB chunk is a couple of hundred reads, and a multiple of the AES
|
|
20
|
+
// block so a full pass never needs the partial-block path.
|
|
21
|
+
const IN_PLACE_BLOCK = 8 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
export function newSalt() {
|
|
24
|
+
return randomBytes(SALT_BYTES).toString('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fresh for every attempt at a chunk, never derived from its index: a run that dies halfway
|
|
28
|
+
// through chunk 3 leaves parts of it on Telegram's servers, and a derived nonce would put the
|
|
29
|
+
// next run's chunk 3 — possibly different bytes, if the file changed — under the same keystream.
|
|
30
|
+
export function newIv() {
|
|
31
|
+
return randomBytes(IV_BYTES).toString('hex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Two keys, because GCM is CTR inside: one key used for both would let a counter block of the
|
|
35
|
+
// manifest seal coincide with a counter block of some chunk.
|
|
36
|
+
export async function deriveKeys(password, salt) {
|
|
37
|
+
const saltBytes = Buffer.from(salt, 'hex')
|
|
38
|
+
const master = await derive(String(password).normalize('NFC'), saltBytes, KEY_BYTES, SCRYPT)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
chunkKey: Buffer.from(hkdfSync('sha256', master, saltBytes, 'telstore v2 chunk key', KEY_BYTES)),
|
|
42
|
+
manifestKey: Buffer.from(hkdfSync('sha256', master, saltBytes, 'telstore v2 manifest key', KEY_BYTES)),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// What an unfinished upload keeps on disk instead of the password: enough to refuse a resume
|
|
47
|
+
// under a different one, which would put two keys into one backup.
|
|
48
|
+
export function passwordCheck(keys) {
|
|
49
|
+
return createHmac('sha256', keys.manifestKey).update('telstore v2 password check').digest('hex')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// CTR at any offset inside a chunk: the counter block for byte `offset` is the chunk's iv
|
|
53
|
+
// followed by the 64-bit block number, and the bytes before `offset` inside that block are
|
|
54
|
+
// discarded. A chunk is at most 1950MB, about 1.3e8 blocks, so the counter never carries into
|
|
55
|
+
// the iv.
|
|
56
|
+
export function chunkCipher(chunkKey, iv) {
|
|
57
|
+
const prefix = Buffer.from(iv, 'hex')
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
apply(bytes, offset) {
|
|
61
|
+
const counter = Buffer.alloc(BLOCK)
|
|
62
|
+
prefix.copy(counter, 0)
|
|
63
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(offset / BLOCK)), IV_BYTES)
|
|
64
|
+
|
|
65
|
+
const cipher = createCipheriv('aes-256-ctr', chunkKey, counter)
|
|
66
|
+
const skip = offset % BLOCK
|
|
67
|
+
|
|
68
|
+
if (skip > 0) cipher.update(Buffer.alloc(skip))
|
|
69
|
+
|
|
70
|
+
return Buffer.concat([cipher.update(bytes), cipher.final()])
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Built from the fields, in one fixed order, and never by serializing the parsed object again:
|
|
76
|
+
// an array has one serialization, while an object's depends on the key order of a file a person
|
|
77
|
+
// can edit. Every field is covered, the readable ones included. That proves the hint genuine only
|
|
78
|
+
// once a password opens the seal, and it is printed before then, so it is also made safe to print
|
|
79
|
+
// (terminalSafe in src/caption.js) rather than trusted because of this.
|
|
80
|
+
export function additionalData(manifest) {
|
|
81
|
+
return Buffer.from(
|
|
82
|
+
JSON.stringify([
|
|
83
|
+
'telstore-enc-v2',
|
|
84
|
+
manifest.id,
|
|
85
|
+
manifest.name,
|
|
86
|
+
manifest.size,
|
|
87
|
+
manifest.chunkSize,
|
|
88
|
+
manifest.createdAt,
|
|
89
|
+
manifest.note ?? null,
|
|
90
|
+
manifest.enc.salt,
|
|
91
|
+
manifest.enc.hint ?? null,
|
|
92
|
+
manifest.chunks.map((chunk) => [chunk.i, chunk.msgId, chunk.size, chunk.sha256, chunk.iv]),
|
|
93
|
+
]),
|
|
94
|
+
'utf8',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function sealManifest(manifest, keys, plainSha256) {
|
|
99
|
+
const nonce = randomBytes(NONCE_BYTES)
|
|
100
|
+
const cipher = createCipheriv('aes-256-gcm', keys.manifestKey, nonce)
|
|
101
|
+
|
|
102
|
+
cipher.setAAD(additionalData(manifest))
|
|
103
|
+
|
|
104
|
+
const body = Buffer.concat([cipher.update(JSON.stringify({ plainSha256 }), 'utf8'), cipher.final()])
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...manifest,
|
|
108
|
+
enc: {
|
|
109
|
+
...manifest.enc,
|
|
110
|
+
sealed: Buffer.concat([nonce, cipher.getAuthTag(), body]).toString('base64'),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Null when the tag fails, which is one failure with two causes — a wrong password or an
|
|
116
|
+
// altered manifest — and the caller is the one that decides whether to ask again. Past the tag
|
|
117
|
+
// the contents are ours, so anything malformed there is a telstore that disagreed with this one
|
|
118
|
+
// about the format, and that throws rather than reading as a wrong password.
|
|
119
|
+
export async function openManifest(manifest, password) {
|
|
120
|
+
const keys = await deriveKeys(password, manifest.enc.salt)
|
|
121
|
+
const sealed = Buffer.from(manifest.enc.sealed, 'base64')
|
|
122
|
+
|
|
123
|
+
if (sealed.length <= NONCE_BYTES + TAG_BYTES) return null
|
|
124
|
+
|
|
125
|
+
const decipher = createDecipheriv('aes-256-gcm', keys.manifestKey, sealed.subarray(0, NONCE_BYTES))
|
|
126
|
+
decipher.setAAD(additionalData(manifest))
|
|
127
|
+
decipher.setAuthTag(sealed.subarray(NONCE_BYTES, NONCE_BYTES + TAG_BYTES))
|
|
128
|
+
|
|
129
|
+
let text
|
|
130
|
+
try {
|
|
131
|
+
text = Buffer.concat([
|
|
132
|
+
decipher.update(sealed.subarray(NONCE_BYTES + TAG_BYTES)),
|
|
133
|
+
decipher.final(),
|
|
134
|
+
]).toString('utf8')
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let inside = null
|
|
140
|
+
try {
|
|
141
|
+
inside = JSON.parse(text)
|
|
142
|
+
} catch {
|
|
143
|
+
// Falls through to the refusal below.
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hashes = inside?.plainSha256
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
!Array.isArray(hashes) ||
|
|
150
|
+
hashes.length !== manifest.chunks.length ||
|
|
151
|
+
!hashes.every((hash) => typeof hash === 'string' && SHA256.test(hash))
|
|
152
|
+
) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`The manifest of ${manifest.id} opened, but what is sealed inside it is not what telstore ` +
|
|
155
|
+
'writes. Refusing to restore from it.',
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { keys, plainSha256: hashes }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readFully(handle, buffer, length, position) {
|
|
163
|
+
let filled = 0
|
|
164
|
+
|
|
165
|
+
while (filled < length) {
|
|
166
|
+
const { bytesRead } = await handle.read(buffer, filled, length - filled, position + filled)
|
|
167
|
+
|
|
168
|
+
if (bytesRead === 0) {
|
|
169
|
+
throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
filled += bytesRead
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function writeFully(handle, buffer, position) {
|
|
177
|
+
let written = 0
|
|
178
|
+
|
|
179
|
+
while (written < buffer.length) {
|
|
180
|
+
const { bytesWritten } = await handle.write(buffer, written, buffer.length - written, position + written)
|
|
181
|
+
|
|
182
|
+
// readFully's guard, for the same reason: a write that makes no progress would spin here
|
|
183
|
+
// forever rather than fail, and a .partial half-decrypted with nobody told is the worst end.
|
|
184
|
+
if (bytesWritten === 0) {
|
|
185
|
+
throw new Error(`Short write: ${buffer.length - written} bytes at offset ${position + written} would not go to disk.`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
written += bytesWritten
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reads a range that holds a verified chunk's ciphertext, writes its plaintext back over it,
|
|
193
|
+
// and hashes the plaintext on the way. Called only after the ciphertext sha256 has matched, so
|
|
194
|
+
// the hash it returns is the second check, not the first.
|
|
195
|
+
export async function decryptInPlace(handle, offset, length, cipher, { blockSize = IN_PLACE_BLOCK } = {}) {
|
|
196
|
+
const hash = createHash('sha256')
|
|
197
|
+
const buffer = Buffer.allocUnsafe(Math.min(blockSize, Math.max(length, 1)))
|
|
198
|
+
|
|
199
|
+
for (let at = 0; at < length; at += blockSize) {
|
|
200
|
+
const size = Math.min(blockSize, length - at)
|
|
201
|
+
|
|
202
|
+
await readFully(handle, buffer, size, offset + at)
|
|
203
|
+
|
|
204
|
+
const clear = cipher.apply(buffer.subarray(0, size), at)
|
|
205
|
+
|
|
206
|
+
hash.update(clear)
|
|
207
|
+
await writeFully(handle, clear, offset + at)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return hash.digest('hex')
|
|
211
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
import { parseArgs } from 'node:util'
|
|
3
3
|
|
|
4
|
+
import { deleteCommand } from './shell.js'
|
|
5
|
+
import { archiveName } from './tar.js'
|
|
6
|
+
|
|
7
|
+
// The shortcuts dispatch by name, not through SUBCOMMANDS: what they return is not a command
|
|
8
|
+
// called `tarc`, it is an upload (or, for `tarx`, a restore) with the line already
|
|
9
|
+
// rewritten into the form that command understands — there is no `runTarc` for SUBCOMMANDS to
|
|
10
|
+
// route to. They still belong in the set below all the same, because SUBCOMMANDS is the list of
|
|
11
|
+
// words telstore will not read as a file name, and a shortcut claims one exactly as a
|
|
12
|
+
// subcommand does.
|
|
13
|
+
const SHORTCUTS = new Map([
|
|
14
|
+
['tarc', tarcLine],
|
|
15
|
+
['tarx', tarxLine],
|
|
16
|
+
])
|
|
17
|
+
|
|
4
18
|
const SUBCOMMANDS = new Set([
|
|
5
19
|
'login',
|
|
6
20
|
'logout',
|
|
21
|
+
'down',
|
|
7
22
|
'list',
|
|
8
23
|
'restore',
|
|
9
24
|
'verify',
|
|
10
25
|
'delete',
|
|
26
|
+
'join',
|
|
11
27
|
'status',
|
|
12
28
|
'config',
|
|
13
29
|
'token',
|
|
14
30
|
'help',
|
|
31
|
+
...SHORTCUTS.keys(),
|
|
15
32
|
])
|
|
16
33
|
|
|
17
|
-
const OPTIONS = {
|
|
34
|
+
export const OPTIONS = {
|
|
18
35
|
chat: { type: 'string' },
|
|
19
36
|
'chunk-size': { type: 'string' },
|
|
20
37
|
'upload-concurrency': { type: 'string' },
|
|
21
38
|
'download-concurrency': { type: 'string' },
|
|
22
39
|
out: { type: 'string' },
|
|
23
40
|
note: { type: 'string' },
|
|
41
|
+
encrypt: { type: 'boolean' },
|
|
24
42
|
limit: { type: 'string' },
|
|
25
43
|
search: { type: 'string' },
|
|
26
44
|
verbose: { type: 'boolean' },
|
|
@@ -35,14 +53,20 @@ export const HELP = `telstore — split large files into chunks and store them o
|
|
|
35
53
|
Usage:
|
|
36
54
|
npx telstore login Log in to Telegram, only needed once
|
|
37
55
|
npx telstore <file|folder|pattern>... Split files and upload them to Telegram
|
|
56
|
+
npx telstore <name> -- <command>... Store what a command writes, under <name>
|
|
57
|
+
npx telstore tarc <name> <path>... Archive paths with tar and store the archive
|
|
58
|
+
npx telstore tarx <backup-id> Restore a backup and extract it with tar
|
|
59
|
+
npx telstore restore <id> -- <cmd>... Restore onto a command instead of a file
|
|
38
60
|
npx telstore list List the backups stored in the destination
|
|
39
61
|
npx telstore list --search <text> List only the backups that text appears in
|
|
40
62
|
npx telstore restore <backup-id>... Download the chunks and reassemble the files
|
|
41
63
|
npx telstore verify <backup-id>... Check that a backup's chunks are all still in the chat
|
|
42
64
|
npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
|
|
65
|
+
npx telstore join <manifest.json> Reassemble chunks you downloaded by hand, offline
|
|
43
66
|
npx telstore status Show the account, the destination and unfinished uploads and restores
|
|
44
67
|
npx telstore config Show every setting and where its value comes from
|
|
45
68
|
npx telstore logout Remove the saved session
|
|
69
|
+
npx telstore down Remove everything telstore keeps on this machine
|
|
46
70
|
|
|
47
71
|
Running on a machine you do not trust:
|
|
48
72
|
npx telstore token Print a session token for another machine
|
|
@@ -54,11 +78,42 @@ backup. A folder means the files one level inside it, and a pattern means the na
|
|
|
54
78
|
one file is listed and confirmed before the first byte goes out. Run telstore again with only
|
|
55
79
|
the files that are left to carry on after an interruption.
|
|
56
80
|
|
|
81
|
+
A name followed by -- makes the backup out of what a command writes, so nothing has to be on
|
|
82
|
+
disk first: npx telstore a.tar -- tar cf - ./a. The manifest goes out only if that command's
|
|
83
|
+
output ended and the command exited 0 — an end after a crash looks exactly like an end after
|
|
84
|
+
success, and running the command is how telstore tells them apart. A backup made this way
|
|
85
|
+
cannot be resumed, so a run that fails, and a Ctrl-C, remove the chunks already sent rather
|
|
86
|
+
than keeping them for a second run there will never be. No shell stands in between: a pipeline
|
|
87
|
+
goes in as -- bash -c 'set -o pipefail; ...', which is also where compression or encryption
|
|
88
|
+
belongs. The pipefail is not decoration — a shell reports the last command's exit status, so
|
|
89
|
+
without it a producer that dies halfway through a pipeline still exits 0 and the manifest goes
|
|
90
|
+
out for a truncated backup.
|
|
91
|
+
|
|
92
|
+
tarc and tarx are the common case written out once: "npx telstore tarc a.tar.gz ./dir"
|
|
93
|
+
is "npx telstore a.tar.gz -- tar czf - ./dir", and tarx is the same for "restore <id> --
|
|
94
|
+
tar xzf -". telstore prints the long form as it runs, so the shortcut teaches what it is
|
|
95
|
+
short for. tarc always compresses, so it makes the name say so: a.tar becomes a.tar.gz,
|
|
96
|
+
and a name with no tar in it at all gets .tar.gz. --verbose adds tar's own file listing
|
|
97
|
+
to both. For tarx, --out is the directory it extracts into, and tar's own semantics apply
|
|
98
|
+
there: it overwrites files already in it without asking, unlike restore's one-file [y/N]
|
|
99
|
+
prompt — --out is how you aim it somewhere empty instead. Anything beyond archiving
|
|
100
|
+
the paths — -C, --exclude, a pipeline, another compressor — is what -- is still for.
|
|
101
|
+
|
|
102
|
+
down is logout taken all the way: it removes ~/.telstore entirely — the session, the api_id
|
|
103
|
+
and api_hash, every setting and every resume record — and asks once before it does. It opens
|
|
104
|
+
no connection and deletes nothing from Telegram: the backups stay in the chat, and the session
|
|
105
|
+
stays alive on Telegram's side until you terminate it under Settings → Devices.
|
|
106
|
+
|
|
57
107
|
restore, verify and delete take several ids the same way: one connection, one line each, and
|
|
58
108
|
an exit code that reports any that failed. delete shows everything it is about to destroy and
|
|
59
109
|
asks once. verify downloads nothing: it asks the chat whether every chunk message is still
|
|
60
110
|
there at the length the manifest records, which is what restore would need.
|
|
61
111
|
|
|
112
|
+
join is restore without Telegram, for chunks you downloaded yourself — from Telegram web, say.
|
|
113
|
+
Put the manifest and every <id>.partNNNN file in one folder under the names they have in the
|
|
114
|
+
chat, and point join at the manifest. It needs no login and opens no connection, and it checks
|
|
115
|
+
each chunk's size and sha256 against the manifest before the file takes its real name.
|
|
116
|
+
|
|
62
117
|
Settings:
|
|
63
118
|
npx telstore config <name> Print one setting's value
|
|
64
119
|
npx telstore config <name> <value> Change it for good
|
|
@@ -77,14 +132,20 @@ Options apply to one run and are never saved. Use config to change a setting for
|
|
|
77
132
|
size it started with.
|
|
78
133
|
--upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
|
|
79
134
|
--download-concurrency <n> 8MB slices in parallel while restoring, this run only.
|
|
80
|
-
--out <path> Where to write the restored file. Defaults to the
|
|
81
|
-
the manifest, and works with one backup id only.
|
|
135
|
+
--out <path> Where to write the restored or joined file. Defaults to the
|
|
136
|
+
basename in the manifest, and works with one backup id only.
|
|
82
137
|
--note <text> A note to store with the upload. It goes into the manifest and
|
|
83
138
|
onto the manifest message, where Telegram's own search can find
|
|
84
139
|
it, and every file of a batch gets the same one. A note with
|
|
85
140
|
spaces in it has to be quoted — --note "march archive" — or the
|
|
86
141
|
shell hands the words after the first to telstore as more files
|
|
87
142
|
to upload.
|
|
143
|
+
--encrypt Encrypt the contents with a password before they leave this
|
|
144
|
+
machine. Asks for the password twice and for an optional hint,
|
|
145
|
+
which is shown in the chat as plain text. restore, tarx and join
|
|
146
|
+
see from the manifest that a backup is encrypted and ask for the
|
|
147
|
+
password themselves. The file name, note and size stay readable,
|
|
148
|
+
and a forgotten password is a lost backup.
|
|
88
149
|
--limit <n> How many backups list shows this run.
|
|
89
150
|
--search <text> List only the backups whose file name, note, backup id or
|
|
90
151
|
creation day contains this text. Telegram's own index does
|
|
@@ -96,7 +157,8 @@ Options apply to one run and are never saved. Use config to change a setting for
|
|
|
96
157
|
purpose: a token written on the command line would sit in
|
|
97
158
|
"ps" for the whole life of the command, and stay in that
|
|
98
159
|
machine's shell history afterwards.
|
|
99
|
-
--yes Upload a batch,
|
|
160
|
+
--yes Upload a batch, delete, or wipe this machine with down,
|
|
161
|
+
without being asked to confirm.
|
|
100
162
|
--verbose Show Telegram connection logs for this run.
|
|
101
163
|
-h, --help Show this help.
|
|
102
164
|
`
|
|
@@ -105,7 +167,54 @@ Options apply to one run and are never saved. Use config to change a setting for
|
|
|
105
167
|
// finished chunk to a state file, restore has not. Naming the backup matters because the
|
|
106
168
|
// id is what `status` lists and what a later `restore` needs — the chunks are already in
|
|
107
169
|
// the chat under that id, whether or not this run ever finishes.
|
|
108
|
-
export function interruptMessage(
|
|
170
|
+
export function interruptMessage(
|
|
171
|
+
command,
|
|
172
|
+
{ backupId, done = [], stream = false, again = false, chat = null } = {},
|
|
173
|
+
) {
|
|
174
|
+
// A backup made from a command is the one upload Ctrl-C cannot leave where it is. The bytes
|
|
175
|
+
// have gone past and the next run cuts them differently, so a chunk already in the chat is
|
|
176
|
+
// a chunk no manifest will ever name — which is why this run is asked to remove them and
|
|
177
|
+
// the process waits, rather than promising the resume the file wording promises.
|
|
178
|
+
if (command === 'upload' && stream) {
|
|
179
|
+
// Nothing is in the chat until there is an id to put it under, and a run stopped before
|
|
180
|
+
// that has nothing for anyone to clean up.
|
|
181
|
+
if (!backupId) {
|
|
182
|
+
return '\nStopped before anything was sent.\n'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (again) {
|
|
186
|
+
// "may still" because this is said while the removal is halfway through and nobody
|
|
187
|
+
// knows how far it got. `deleteCommand` is what names the chat, and why: a later
|
|
188
|
+
// `delete` resolves its destination from config, and these ids fired at the wrong peer
|
|
189
|
+
// destroy whatever happens to carry them there. A null chat here is a Ctrl-C that
|
|
190
|
+
// landed before the run said where it was sending — the chatless branch documented
|
|
191
|
+
// beside that function is for exactly this caller.
|
|
192
|
+
const removal = deleteCommand(backupId, chat)
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
`\nLeaving now. Backup ${backupId} may still have chunks in the chat with no manifest ` +
|
|
196
|
+
`pointing at them — run "${removal}" to remove them.\n`
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
`\nStopping. Backup ${backupId} was made from a command and cannot be resumed, so ` +
|
|
202
|
+
'telstore is removing the chunks it already sent. This takes a moment — press Ctrl-C ' +
|
|
203
|
+
'again to leave now and clean up by hand.\n'
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The restore direction of the same idea, and the difference is the whole message: a stream
|
|
208
|
+
// upload has to unwind what it put in the chat, while this one put nothing there. What it
|
|
209
|
+
// cannot put back is what the command already did with the bytes it was given.
|
|
210
|
+
if (command === 'restore' && stream) {
|
|
211
|
+
return (
|
|
212
|
+
'\nStopped. Nothing in the chat changed and nothing was kept on this machine, but the ' +
|
|
213
|
+
'command had already been given part of the backup, so whatever it wrote from that is ' +
|
|
214
|
+
'incomplete. A restore into a command cannot be resumed — run it again from the start.\n'
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
109
218
|
if (command === 'upload') {
|
|
110
219
|
const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
|
|
111
220
|
|
|
@@ -176,9 +285,28 @@ export function interruptMessage(command, { backupId, done = [] } = {}) {
|
|
|
176
285
|
)
|
|
177
286
|
}
|
|
178
287
|
|
|
288
|
+
// A join writes nothing under the real name until the end, and the half-joined file is
|
|
289
|
+
// removed on the way out, so there is nothing to carry on from — and nothing in the chat
|
|
290
|
+
// it could have touched.
|
|
291
|
+
if (command === 'join') {
|
|
292
|
+
return '\nStopped. Nothing was joined — run the same command again to start over.\n'
|
|
293
|
+
}
|
|
294
|
+
|
|
179
295
|
return '\nStopped.\n'
|
|
180
296
|
}
|
|
181
297
|
|
|
298
|
+
// The one `--` this file did not write. protectNegativeChatIds, below, inserts one of its
|
|
299
|
+
// own to rescue a negative chat id from parseArgs, so the position has to be taken off the
|
|
300
|
+
// argv as typed — afterwards the two are indistinguishable, and `config chat -100123` would
|
|
301
|
+
// become a command telstore tries to run.
|
|
302
|
+
function splitAtTerminator(argv) {
|
|
303
|
+
const at = argv.indexOf('--')
|
|
304
|
+
|
|
305
|
+
if (at === -1) return { head: argv, childArgv: null }
|
|
306
|
+
|
|
307
|
+
return { head: argv.slice(0, at), childArgv: argv.slice(at + 1) }
|
|
308
|
+
}
|
|
309
|
+
|
|
182
310
|
// A channel id is negative, and typing it separated by a space is the natural reflex — but
|
|
183
311
|
// parseArgs rejects anything starting with a dash as an option, and reports it as one:
|
|
184
312
|
// `config chat -100123` fails with "Unknown option '-1'", naming a flag nobody typed.
|
|
@@ -233,9 +361,80 @@ function filesNamedAfterNote(tokens) {
|
|
|
233
361
|
return tokens.some((token) => token.kind === 'positional' && token.index > note.index)
|
|
234
362
|
}
|
|
235
363
|
|
|
236
|
-
|
|
364
|
+
// tarc is the long form with the three decisions that never change already made: `c` for
|
|
365
|
+
// create, `z` for gzip, `f -` for "write it to stdout, which is where telstore is listening".
|
|
366
|
+
// The missing `-` is not a hypothetical mistake — this project's own help text and README
|
|
367
|
+
// shipped exactly that omission once, and paid for it with an example that exited 2 instead
|
|
368
|
+
// of writing a backup.
|
|
369
|
+
//
|
|
370
|
+
// An expansion rather than a command of its own: `runStreamUpload` is reached with exactly the
|
|
371
|
+
// argv the `--` form reaches it with, so there is no second upload path, no second rollback
|
|
372
|
+
// and no second guarantee. It also prints that argv, so the shortcut teaches the long form
|
|
373
|
+
// instead of hiding it.
|
|
374
|
+
function tarcLine(rest, values, filesAfterNote) {
|
|
375
|
+
const [name, ...paths] = rest
|
|
376
|
+
|
|
377
|
+
if (name === undefined) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
'Missing a name for the backup. tarc stores the archive under a name you choose. ' +
|
|
380
|
+
'Example: npx telstore tarc a.tar.gz ./a',
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Refused rather than answered with a guess: a rule that read one positional as a name and
|
|
385
|
+
// two as a name plus a path would make `telstore tarc ./x ./y` archive ./y under the name
|
|
386
|
+
// ./x, which is the silent wrong answer this project exists to refuse.
|
|
387
|
+
if (paths.length === 0) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Nothing to archive: tarc needs the paths to put in ${name}. ` +
|
|
390
|
+
'Example: npx telstore tarc a.tar.gz ./a',
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
command: 'upload',
|
|
396
|
+
args: [archiveName(name)],
|
|
397
|
+
options: values,
|
|
398
|
+
filesAfterNote,
|
|
399
|
+
childArgv: ['tar', values.verbose ? 'czvf' : 'czf', '-', ...paths],
|
|
400
|
+
shortcut: 'tarc',
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The mirror of tarcLine. `x` for extract, `z` because tarc always compressed, `f -` because
|
|
405
|
+
// the bytes arrive on stdin.
|
|
406
|
+
function tarxLine(rest, values, filesAfterNote) {
|
|
407
|
+
requireOneBackupId(rest, 'tarx')
|
|
408
|
+
|
|
409
|
+
const childArgv = ['tar', values.verbose ? 'xzvf' : 'xzf', '-']
|
|
410
|
+
|
|
411
|
+
// Pushed after `-` on purpose, which is the order measured to work on GNU tar 1.35:
|
|
412
|
+
// `tar xzf - -C ./here`. See the probe table in the spec.
|
|
413
|
+
if (values.out !== undefined) childArgv.push('-C', values.out)
|
|
414
|
+
|
|
415
|
+
return { command: 'restore', args: rest, options: values, filesAfterNote, childArgv, shortcut: 'tarx' }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// One command reads one stream, so a line that names two backups is a line with no answer:
|
|
419
|
+
// extracting two archives into one working directory in sequence is a question nobody asked.
|
|
420
|
+
function requireOneBackupId(ids, what) {
|
|
421
|
+
if (ids.length === 0) {
|
|
422
|
+
throw new Error(`Missing backup id. Example: npx telstore ${what} telstore-20260905-7f3a91`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (ids.length > 1) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`One command reads one stream, so ${what} takes one backup id and got ${ids.length}: ` +
|
|
428
|
+
`${ids.join(', ')}. Run telstore once per backup.`,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function routeLine(argv) {
|
|
434
|
+
const { head, childArgv } = splitAtTerminator(argv)
|
|
435
|
+
|
|
237
436
|
const { values, positionals, tokens } = parseArgs({
|
|
238
|
-
args: protectNegativeChatIds(
|
|
437
|
+
args: protectNegativeChatIds(head),
|
|
239
438
|
options: OPTIONS,
|
|
240
439
|
allowPositionals: true,
|
|
241
440
|
tokens: true,
|
|
@@ -244,26 +443,135 @@ export function route(argv) {
|
|
|
244
443
|
const [first, ...rest] = positionals
|
|
245
444
|
const filesAfterNote = filesNamedAfterNote(tokens)
|
|
246
445
|
|
|
446
|
+
// --help (or -h, or the `help` subcommand) always wins, terminator or not: someone typing
|
|
447
|
+
// `telstore --help -- tar cf - ./a` is asking what telstore does, not making a mistake for
|
|
448
|
+
// one of the checks below to catch.
|
|
449
|
+
if (values.help || first === 'help') {
|
|
450
|
+
return { command: 'help', args: [], options: values, filesAfterNote, childArgv, shortcut: null }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (childArgv !== null && childArgv.length === 0) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
'Missing the command after --: telstore has nothing to run and store. ' +
|
|
456
|
+
'Example: npx telstore a.tar -- tar cf - ./a',
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A terminator changes what "no name" and "which command" mean, so it is read before the
|
|
461
|
+
// ordinary help/chat fallbacks get a chance to answer for it — those apply to a line that
|
|
462
|
+
// never named a command to run at all.
|
|
463
|
+
if (childArgv !== null) {
|
|
464
|
+
// Reached before the generic "takes no command after --" below, because for these two the
|
|
465
|
+
// reason is different and so is the way out: they are not a subcommand that happens not to
|
|
466
|
+
// run commands, they are a command already.
|
|
467
|
+
if (SHORTCUTS.has(first)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`${first} already is the command it runs, so it cannot be followed by another one. ` +
|
|
470
|
+
`Drop the -- to use ${first}, or drop ${first} to write the command out yourself.`,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (first !== undefined && SUBCOMMANDS.has(first)) {
|
|
475
|
+
// restore is let through rather than refused here, and the binary is what turns it away.
|
|
476
|
+
// The shape is the spec's stage 2, so the parser keeps it whole — but a refusal that
|
|
477
|
+
// says "not built yet, restore to a file and pipe that" belongs where the command runs,
|
|
478
|
+
// beside the alternative it is offering, not in an argument parser.
|
|
479
|
+
if (first !== 'restore') {
|
|
480
|
+
throw new Error(
|
|
481
|
+
`${first} takes no command after --. An upload (npx telstore a.tar -- tar cf - ./a) ` +
|
|
482
|
+
'and a restore (npx telstore restore <id> -- tar xf -) are the two that run one.',
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
requireOneBackupId(rest, 'restore')
|
|
487
|
+
|
|
488
|
+
// --out places a file, and this path writes none: the bytes go to the command on its
|
|
489
|
+
// stdin. Left to pass silently it would read as "restore into the command AND write
|
|
490
|
+
// the file over there", which is not what happens.
|
|
491
|
+
if (values.out !== undefined) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
'A restore into a command writes no file, so --out has nothing to place: the bytes ' +
|
|
494
|
+
'go to the command on its stdin. Tell the command where to put them instead ' +
|
|
495
|
+
'(npx telstore restore <id> -- tar xf - -C ./here).',
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { command: first, args: rest, options: values, filesAfterNote, childArgv, shortcut: null }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (positionals.length === 0) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
'Missing a name before --. telstore stores what the command writes under a name you ' +
|
|
505
|
+
'choose, and there is nothing to take one from. Example: npx telstore a.tar -- tar cf - ./a',
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (positionals.length > 1) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`One command produces one stream, so telstore takes one name before -- and got ` +
|
|
512
|
+
`${positionals.length}: ${positionals.join(', ')}. Run telstore once per backup.`,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { command: 'upload', args: positionals, options: values, filesAfterNote, childArgv, shortcut: null }
|
|
517
|
+
}
|
|
518
|
+
|
|
247
519
|
// `telstore --chat @chan` with no file used to mean "remember this destination". Flags no
|
|
248
520
|
// longer write anything, so that line now asks for a run that has nothing to upload —
|
|
249
521
|
// say where the destination actually lives instead of printing help at someone who was
|
|
250
|
-
// perfectly clear about what they wanted.
|
|
251
|
-
|
|
522
|
+
// perfectly clear about what they wanted. (values.help already returned above, so reaching
|
|
523
|
+
// here means it was never set.)
|
|
524
|
+
if (first === undefined && values.chat) {
|
|
252
525
|
throw new Error(
|
|
253
526
|
`Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.chat}". ` +
|
|
254
527
|
'To use it for one run, pass --chat alongside a file or a command.',
|
|
255
528
|
)
|
|
256
529
|
}
|
|
257
530
|
|
|
258
|
-
if (
|
|
259
|
-
return { command: 'help', args: [], options: values, filesAfterNote }
|
|
531
|
+
if (first === undefined) {
|
|
532
|
+
return { command: 'help', args: [], options: values, filesAfterNote, childArgv, shortcut: null }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const shortcut = SHORTCUTS.get(first)
|
|
536
|
+
if (shortcut) return shortcut(rest, values, filesAfterNote)
|
|
537
|
+
|
|
538
|
+
// --out names one file, and each manifest is one file: a second manifest on the line would
|
|
539
|
+
// have to be written somewhere nobody said.
|
|
540
|
+
if (first === 'join') {
|
|
541
|
+
if (rest.length === 0) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
'Missing the manifest to join. Example: npx telstore join ./telstore-20260905-7f3a91.manifest.json',
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (rest.length > 1) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`join takes one manifest and got ${rest.length}: ${rest.join(', ')}. Run it once per backup.`,
|
|
550
|
+
)
|
|
551
|
+
}
|
|
260
552
|
}
|
|
261
553
|
|
|
262
554
|
if (SUBCOMMANDS.has(first)) {
|
|
263
|
-
return { command: first, args: rest, options: values, filesAfterNote }
|
|
555
|
+
return { command: first, args: rest, options: values, filesAfterNote, childArgv, shortcut: null }
|
|
264
556
|
}
|
|
265
557
|
|
|
266
558
|
// Every positional, not just the first: `telstore a b c` used to upload `a` and drop the
|
|
267
559
|
// rest without a word, which is the one thing this project never does.
|
|
268
|
-
return { command: 'upload', args: positionals, options: values, filesAfterNote }
|
|
560
|
+
return { command: 'upload', args: positionals, options: values, filesAfterNote, childArgv, shortcut: null }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Encryption is decided when a backup is made; every command that reads one learns it from the
|
|
564
|
+
// manifest. A flag beside a restore would read as "decrypt with this", which it would not be,
|
|
565
|
+
// and a flag that silently does nothing is one nobody can predict without the source.
|
|
566
|
+
export function route(argv) {
|
|
567
|
+
const parsed = routeLine(argv)
|
|
568
|
+
|
|
569
|
+
if (parsed.options.encrypt && parsed.command !== 'upload' && parsed.command !== 'help') {
|
|
570
|
+
throw new Error(
|
|
571
|
+
'--encrypt applies to uploads only. restore, tarx and join see from the manifest that a ' +
|
|
572
|
+
'backup is encrypted and ask for its password by themselves.',
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return parsed
|
|
269
577
|
}
|
package/src/client.js
CHANGED
|
@@ -76,9 +76,15 @@ async function* iterMessagePages(client, peer, { search, what, options }) {
|
|
|
76
76
|
max = Infinity,
|
|
77
77
|
retryOptions = {},
|
|
78
78
|
stallMs = DEFAULT_STALL_MS,
|
|
79
|
+
// Where the walk begins, as the id of the message just above the first one wanted. 0 is
|
|
80
|
+
// "the newest in the chat", which is what list asks for. delete starts at a backup's own
|
|
81
|
+
// manifest instead when the chat has shown it one: the manifest is the last message a
|
|
82
|
+
// backup's run sends, so nothing of that backup is newer, and everything posted since is
|
|
83
|
+
// a page of documents read for nothing.
|
|
84
|
+
offsetId: startId = 0,
|
|
79
85
|
} = options
|
|
80
86
|
|
|
81
|
-
let offsetId =
|
|
87
|
+
let offsetId = startId
|
|
82
88
|
let read = 0
|
|
83
89
|
|
|
84
90
|
while (read < max) {
|