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/password.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { stderr, stdin } from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { parseHint, terminalSafe } from './caption.js'
|
|
4
|
+
import { openManifest } from './cipher.js'
|
|
5
|
+
import { createPrompts, readSecret } from './prompt.js'
|
|
6
|
+
|
|
7
|
+
export const PASSWORD_ATTEMPTS = 3
|
|
8
|
+
|
|
9
|
+
// A password that came from an environment variable, a flag or a file came from somewhere that
|
|
10
|
+
// kept a copy of it, which is the rule src/token.js keeps for a passphrase. Unattended encrypted
|
|
11
|
+
// backups stay on the `--` pipeline with a key-based tool.
|
|
12
|
+
const NO_TERMINAL_UPLOAD =
|
|
13
|
+
'--encrypt needs a terminal to type the password in, and there is none here. Run the upload ' +
|
|
14
|
+
'where you can type it; telstore does not read a password from anywhere else.'
|
|
15
|
+
|
|
16
|
+
// One readline for the whole exchange, as docs/design/terminal-prompts.md requires: two over one
|
|
17
|
+
// stdin do not take turns, and the second question would read nothing. On stderr, so a stdout
|
|
18
|
+
// someone redirected still carries only what telstore reports.
|
|
19
|
+
export async function askNewPassword({ input = stdin, output = stderr } = {}) {
|
|
20
|
+
if (!input.isTTY) throw new Error(NO_TERMINAL_UPLOAD)
|
|
21
|
+
|
|
22
|
+
const prompts = createPrompts({ input, output })
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const password = await prompts.askSecret('Password: ')
|
|
26
|
+
|
|
27
|
+
if (password === '') {
|
|
28
|
+
throw new Error('The password is empty. An encrypted backup needs one — run again and type it.')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const again = await prompts.askSecret('Password again: ')
|
|
32
|
+
|
|
33
|
+
if (again !== password) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'The two passwords are different, so telstore does not know which one you meant. ' +
|
|
36
|
+
'Nothing was sent — run again.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hint = parseHint(await prompts.ask('Hint (optional, shown in the chat as plain text): '), password)
|
|
41
|
+
|
|
42
|
+
return { password, hint }
|
|
43
|
+
} finally {
|
|
44
|
+
prompts.close()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function askPassword(question) {
|
|
49
|
+
return readSecret(question)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Opens an encrypted manifest or says why it cannot. Passwords this run has already seen work
|
|
53
|
+
// are tried first and silently, so a batch of backups under one password asks once. A failed
|
|
54
|
+
// tag cannot tell a wrong password from an altered manifest, so the last refusal names both,
|
|
55
|
+
// likelier first, as src/token.js does for a token.
|
|
56
|
+
export async function unlockManifest(
|
|
57
|
+
manifest,
|
|
58
|
+
{ askPassword: ask, interactive = () => Boolean(stdin.isTTY), known = [], say = () => {} },
|
|
59
|
+
) {
|
|
60
|
+
for (const password of known) {
|
|
61
|
+
const opened = await openManifest(manifest, password)
|
|
62
|
+
if (opened) return { ...opened, password }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!interactive()) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${manifest.id} is encrypted, and there is no terminal here to type its password in. ` +
|
|
68
|
+
'Run this where you can type it; telstore does not read a password from anywhere else.',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
say(`Backup ${manifest.id} is encrypted.`)
|
|
73
|
+
|
|
74
|
+
// Printed before any key exists, so the seal that covers it cannot have been checked yet: at
|
|
75
|
+
// this moment it is as trustworthy as the chat it came from, and is made safe to print as such.
|
|
76
|
+
const hint = manifest.enc.hint ? terminalSafe(manifest.enc.hint) : ''
|
|
77
|
+
|
|
78
|
+
if (hint) say(`Hint ${hint}`)
|
|
79
|
+
|
|
80
|
+
for (let attempt = 1; attempt <= PASSWORD_ATTEMPTS; attempt += 1) {
|
|
81
|
+
const password = await ask('Password: ')
|
|
82
|
+
const opened = password === '' ? null : await openManifest(manifest, password)
|
|
83
|
+
|
|
84
|
+
if (opened) {
|
|
85
|
+
known.push(password)
|
|
86
|
+
return { ...opened, password }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (attempt < PASSWORD_ATTEMPTS) say('That password does not open it. Try again.')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Could not open ${manifest.id} after ${PASSWORD_ATTEMPTS} attempts: either the password is ` +
|
|
94
|
+
'wrong or the manifest was altered. Encryption cannot tell those two apart, so telstore ' +
|
|
95
|
+
'will not guess — check the password first.' +
|
|
96
|
+
(hint
|
|
97
|
+
? ' The hint shown comes from the chat and is only checked once the password opens the ' +
|
|
98
|
+
'backup, so a hint that does not help may itself have been altered.'
|
|
99
|
+
: ''),
|
|
100
|
+
)
|
|
101
|
+
}
|
package/src/progress.js
CHANGED
|
@@ -47,6 +47,57 @@ export function renderProgress({ done, total, elapsedMs, label, width = 24, tran
|
|
|
47
47
|
return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// A percentage of an unknown total is an invented number, and an ETA from one is worse: it
|
|
51
|
+
// would count down to a finish nobody can predict.
|
|
52
|
+
export function renderStreamProgress({ done, elapsedMs, label }) {
|
|
53
|
+
const bytesPerSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0
|
|
54
|
+
|
|
55
|
+
return `${label} ${formatBytes(done)} sent ${formatBytes(Math.round(bytesPerSecond))}/s`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createStreamProgress({
|
|
59
|
+
label,
|
|
60
|
+
write = (line) => process.stderr.write(line),
|
|
61
|
+
now = () => Date.now(),
|
|
62
|
+
minIntervalMs = 200,
|
|
63
|
+
}) {
|
|
64
|
+
const startedAt = now()
|
|
65
|
+
let done = 0
|
|
66
|
+
let currentLabel = label
|
|
67
|
+
let lastDrawnAt = startedAt
|
|
68
|
+
let widestLine = 0
|
|
69
|
+
|
|
70
|
+
// Same \r discipline as createProgress: a redraw shorter than the one before it would
|
|
71
|
+
// leave the previous line's tail on screen, so pad every line out to the widest drawn so far.
|
|
72
|
+
function draw(suffix) {
|
|
73
|
+
const line = renderStreamProgress({ done, elapsedMs: now() - startedAt, label: currentLabel })
|
|
74
|
+
widestLine = Math.max(widestLine, line.length)
|
|
75
|
+
write(`\r${line.padEnd(widestLine)}${suffix}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
advance(bytes) {
|
|
80
|
+
done += bytes
|
|
81
|
+
if (now() - lastDrawnAt < minIntervalMs) return
|
|
82
|
+
lastDrawnAt = now()
|
|
83
|
+
draw('')
|
|
84
|
+
},
|
|
85
|
+
setLabel(next) {
|
|
86
|
+
currentLabel = next
|
|
87
|
+
lastDrawnAt = now()
|
|
88
|
+
draw('')
|
|
89
|
+
},
|
|
90
|
+
finish() {
|
|
91
|
+
draw('\n')
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A number turned into words a person reads: "1 chunk" for one, "3 chunks" for the rest.
|
|
97
|
+
export function plural(n, word) {
|
|
98
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
export function createProgress({
|
|
51
102
|
total,
|
|
52
103
|
label,
|
|
@@ -99,3 +150,38 @@ export function createProgress({
|
|
|
99
150
|
},
|
|
100
151
|
}
|
|
101
152
|
}
|
|
153
|
+
|
|
154
|
+
// A walk of one page is over in about the time it takes to notice — 165ms against a real
|
|
155
|
+
// chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
|
|
156
|
+
// appears and is wiped in the same breath is a flicker, not information. Past that the read
|
|
157
|
+
// is long enough that silence reads as the hang this project refuses everywhere else.
|
|
158
|
+
//
|
|
159
|
+
// \r only moves the cursor home, so every line is padded to the widest one drawn and the last
|
|
160
|
+
// write wipes the row: whatever the command prints next must never land on half a notice.
|
|
161
|
+
//
|
|
162
|
+
// Here rather than beside either caller: `list` walks a chat to find backups and `delete`
|
|
163
|
+
// walks it to find chunks nothing on this machine names, and two copies of "when is a read
|
|
164
|
+
// long enough to say something about" is how they start disagreeing about it.
|
|
165
|
+
const NOTICE_QUIET_MS = 400
|
|
166
|
+
const NOTICE_INTERVAL_MS = 200
|
|
167
|
+
|
|
168
|
+
export function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
|
|
169
|
+
const startedAt = now()
|
|
170
|
+
let lastDrawnAt = 0
|
|
171
|
+
let widest = 0
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
tick(text) {
|
|
175
|
+
if (now() - startedAt < quietMs) return
|
|
176
|
+
if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
|
|
177
|
+
|
|
178
|
+
lastDrawnAt = now()
|
|
179
|
+
widest = Math.max(widest, text.length)
|
|
180
|
+
write(`\r${text.padEnd(widest)}`)
|
|
181
|
+
},
|
|
182
|
+
clear() {
|
|
183
|
+
if (widest === 0) return
|
|
184
|
+
write(`\r${' '.repeat(widest)}\r`)
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/shell.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A command meant to be pasted has to survive the shell that receives it: anything a shell
|
|
2
|
+
// would take apart comes back quoted, and a path with a space in it is the ordinary case,
|
|
3
|
+
// not an exotic one. `status` and `down` both print resume commands, and two copies of this
|
|
4
|
+
// rule is how they start disagreeing about which paths are safe to print bare.
|
|
5
|
+
const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
|
|
6
|
+
|
|
7
|
+
export function shellArg(text) {
|
|
8
|
+
const value = String(text)
|
|
9
|
+
|
|
10
|
+
return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The one line in telstore that destroys data when it is wrong. `delete` resolves its own
|
|
14
|
+
// destination from config and then fires the recorded message ids at whatever peer that turns
|
|
15
|
+
// out to be, so a command pasted next week — or one built here under a `--chat` this run was
|
|
16
|
+
// given — would remove whatever happens to carry those ids in the chat it resolves. Naming a
|
|
17
|
+
// chat that turns out to be the default costs a few characters; leaving it out when it is not
|
|
18
|
+
// costs somebody else's messages, and nothing undoes that. Four commands print this string
|
|
19
|
+
// (`upload-stream`'s rollback, `cli`'s second Ctrl-C, `status`, `down`) and they had four
|
|
20
|
+
// copies of the rule, which is how three of them stayed right and one drifted.
|
|
21
|
+
//
|
|
22
|
+
// The chatless branch is not a convenience: it is for the single caller that genuinely does
|
|
23
|
+
// not know where the chunks went. `bin/telstore.js` holds the chat the run handed it, and a
|
|
24
|
+
// Ctrl-C arriving before the run ever reported one leaves the id — the only part of the
|
|
25
|
+
// message with any value — rather than printing `--chat undefined`, which `delete` would read
|
|
26
|
+
// as no destination at all while looking like one. A caller that would rather print nothing
|
|
27
|
+
// than a command missing its chat decides that for itself before calling: `down` does, and
|
|
28
|
+
// says why beside its own check.
|
|
29
|
+
export function deleteCommand(id, chat) {
|
|
30
|
+
const where = chat === null || chat === undefined ? '' : String(chat).trim()
|
|
31
|
+
|
|
32
|
+
return `npx telstore delete ${shellArg(id)}${where === '' ? '' : ` --chat ${shellArg(where)}`}`
|
|
33
|
+
}
|
package/src/spawn.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// stderr is inherited, never captured: when a producer command fails it explains itself in
|
|
4
|
+
// its own words, on the stream the user is already watching. telstore adds the exit code
|
|
5
|
+
// and what it did about it, and does not paraphrase.
|
|
6
|
+
//
|
|
7
|
+
// `exited` can be built before the caller has any chance to await it — the caller reads
|
|
8
|
+
// `stdout` first, and only awaits `exited` once the stream ends. A rejected promise with no
|
|
9
|
+
// handler attached yet makes Node report an unhandledRejection, so a no-op `.catch` is
|
|
10
|
+
// attached here immediately. That does not consume the rejection: the `exited` this function
|
|
11
|
+
// returns is the same promise, and `await`ing it later still resolves or rejects exactly as
|
|
12
|
+
// it would have.
|
|
13
|
+
export function spawnProducer(argv, { stdio = ['ignore', 'pipe', 'inherit'] } = {}) {
|
|
14
|
+
const [command, ...args] = argv
|
|
15
|
+
const child = spawn(command, args, { stdio })
|
|
16
|
+
|
|
17
|
+
const exited = new Promise((resolve, reject) => {
|
|
18
|
+
child.on('error', (err) => {
|
|
19
|
+
reject(
|
|
20
|
+
new Error(
|
|
21
|
+
err.code === 'ENOENT'
|
|
22
|
+
? `Cannot run ${command}: no such command on this machine.`
|
|
23
|
+
: `Cannot run ${command}: ${err.message}`,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
child.on('close', (code, signal) => resolve({ code, signal }))
|
|
29
|
+
})
|
|
30
|
+
exited.catch(() => {})
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
stdout: child.stdout,
|
|
34
|
+
stdin: child.stdin,
|
|
35
|
+
exited,
|
|
36
|
+
kill: (signal = 'SIGTERM') => child.kill(signal),
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,14 +8,97 @@ export function stateDir(configDir = defaultConfigDir()) {
|
|
|
8
8
|
return path.join(configDir, 'state')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// The other thing telstore keeps on this machine, and the only one that is not a record: a
|
|
12
|
+
// stream upload borrows one chunk of disk at a time here while it sends it. Under
|
|
13
|
+
// ~/.telstore rather than os.tmpdir() because /tmp is tmpfs on many Linux distributions, and
|
|
14
|
+
// "borrow one chunk of disk" would silently mean "borrow 1800MB of RAM" — a memory limit
|
|
15
|
+
// dressed up as a chunk size.
|
|
16
|
+
//
|
|
17
|
+
// It lives beside stateDir because it answers the same question — what has this machine got
|
|
18
|
+
// of telstore's on it — and because `status` has to be able to ask without importing the
|
|
19
|
+
// upload command, which would drag a second upload loop and teleproto in with it.
|
|
20
|
+
export function tempDirFor(configDir = defaultConfigDir()) {
|
|
21
|
+
return path.join(configDir, 'tmp')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What is in there now. A run removes its own chunk file on every ending it gets to run code
|
|
25
|
+
// for, so a file here is either a run happening at this moment or a run that was stopped
|
|
26
|
+
// where it stood — a SIGKILL, a crash, a machine losing power. Nothing here removes them:
|
|
27
|
+
// from outside the run that owns one, those two cases look exactly the same, and deleting
|
|
28
|
+
// the chunk a live upload is filling is the confident wrong thing this project refuses
|
|
29
|
+
// everywhere else. Naming them is the whole job.
|
|
30
|
+
//
|
|
31
|
+
// A stat that fails yields an unknown size rather than a dropped row, the same care
|
|
32
|
+
// listStates takes with mtimes: the file really can vanish between the readdir and the stat —
|
|
33
|
+
// that is what a run finishing normally does — and status is the command someone runs
|
|
34
|
+
// *because* something is wrong.
|
|
35
|
+
export async function listTempChunks(configDir = defaultConfigDir()) {
|
|
36
|
+
const dir = tempDirFor(configDir)
|
|
37
|
+
let names
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
names = await fs.readdir(dir)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// A machine that has never made a backup from a command has no such directory, and that
|
|
43
|
+
// is not a fault to report. Anything else is: a directory telstore cannot read may be
|
|
44
|
+
// holding a whole chunk, and answering "nothing there" would be the silent wrong answer
|
|
45
|
+
// this listing exists to prevent. The caller decides what to do with it.
|
|
46
|
+
if (err.code === 'ENOENT') return []
|
|
47
|
+
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const found = []
|
|
52
|
+
|
|
53
|
+
for (const name of names.sort()) {
|
|
54
|
+
const file = path.join(dir, name)
|
|
55
|
+
let size = null
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const stat = await fs.stat(file)
|
|
59
|
+
|
|
60
|
+
if (!stat.isFile()) continue
|
|
61
|
+
|
|
62
|
+
size = stat.size
|
|
63
|
+
} catch {
|
|
64
|
+
// Gone or unreadable between the readdir and here. Still a name worth printing: the
|
|
65
|
+
// point of the listing is that nothing telstore left behind goes unmentioned.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
found.push({ name, file, size })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return found
|
|
72
|
+
}
|
|
73
|
+
|
|
11
74
|
export function stateKey(absPath, size, mtimeMs) {
|
|
12
75
|
return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
|
|
13
76
|
}
|
|
14
77
|
|
|
78
|
+
// Where an encrypted upload's record is filed, and the reason it is not filed under stateKey is
|
|
79
|
+
// the reason the manifest went to version 2: an older telstore. That build computes stateKey
|
|
80
|
+
// exactly as this one does and never reads `enc`, so given an unfinished encrypted record under
|
|
81
|
+
// that key it would resume it without --encrypt, send the remaining chunks in plain, write a
|
|
82
|
+
// version 1 manifest with no ivs in it — and a later restore would match every sha256 and print
|
|
83
|
+
// Done over a file that is half ciphertext. A lookup that misses is the only refusal an older
|
|
84
|
+
// build can be made to give. Same 40-hex shape, so every listing, prune and status report that
|
|
85
|
+
// already handles upload records handles this one without learning a new name.
|
|
86
|
+
export function encryptedStateKey(absPath, size, mtimeMs) {
|
|
87
|
+
return createHash('sha1').update(`enc:${absPath}:${size}:${mtimeMs}`).digest('hex')
|
|
88
|
+
}
|
|
89
|
+
|
|
15
90
|
export function stateFile(key, configDir = defaultConfigDir()) {
|
|
16
91
|
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
92
|
}
|
|
18
93
|
|
|
94
|
+
// stateKey hashes path:size:mtime, and a stream has none of the three. What holds still is
|
|
95
|
+
// the backup id, and hashing it keeps the file name in the same 40-hex shape the directory
|
|
96
|
+
// already sorts, prunes and filters on — a stream record is an upload record, not a third
|
|
97
|
+
// kind, so it shares that namespace rather than getting a prefix of its own.
|
|
98
|
+
export function streamKey(backupId) {
|
|
99
|
+
return createHash('sha1').update(`stream:${backupId}`).digest('hex')
|
|
100
|
+
}
|
|
101
|
+
|
|
19
102
|
// A restore's record is filed beside the uploads and must never compete with them for a
|
|
20
103
|
// prune slot. Losing an upload record strands chunks in a chat where only the id can still
|
|
21
104
|
// find them, which is why pruneStates reads each file back to name what it drops; losing a
|
|
@@ -211,13 +294,21 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
|
211
294
|
// Whether a record can still be resumed, which is not a question about the record alone:
|
|
212
295
|
// runUpload hashes the file it finds on disk and looks the result up, so a backup is
|
|
213
296
|
// resumable exactly when that hash is still the key this record is filed under. Recomputing
|
|
214
|
-
// through stateKey rather than comparing size and
|
|
297
|
+
// through stateKey (encryptedStateKey for an encrypted record) rather than comparing size and
|
|
298
|
+
// mtime by hand is the point — a second way
|
|
215
299
|
// of asking is a second way to drift, and status would end up promising a resume that upload
|
|
216
300
|
// turns into a brand new backup, stranding every chunk already sent.
|
|
217
301
|
//
|
|
218
302
|
// Never throws. status calls this for every record it prints, and one damaged path must not
|
|
219
303
|
// take the rest of the report down with it.
|
|
220
304
|
export async function canResume(key, state) {
|
|
305
|
+
// A stream cannot be resumed by anyone, so this is not a question about a file. Answering
|
|
306
|
+
// it by stat-ing state.path would report "missing" for a record that never had a path,
|
|
307
|
+
// and status would then offer a resume command that starts a brand new backup. This has
|
|
308
|
+
// to run before the stat below, not after it fails: a stream record's key could still
|
|
309
|
+
// happen to match a real file on disk, and that file is not what makes it unresumable.
|
|
310
|
+
if (state.kind === 'stream') return { ok: false, reason: 'stream' }
|
|
311
|
+
|
|
221
312
|
let stat
|
|
222
313
|
|
|
223
314
|
try {
|
|
@@ -228,7 +319,16 @@ export async function canResume(key, state) {
|
|
|
228
319
|
}
|
|
229
320
|
|
|
230
321
|
if (!stat.isFile()) return { ok: false, reason: 'not-a-file' }
|
|
231
|
-
|
|
322
|
+
|
|
323
|
+
// Two keys now, and the record says which one it belongs under: runUpload files an encrypted
|
|
324
|
+
// record under encryptedStateKey and a plain one under stateKey. A record sitting under the
|
|
325
|
+
// other kind's key for a file that has not changed is not a changed file — runUpload refuses
|
|
326
|
+
// it as damaged rather than resume it, and the report has to say the same thing.
|
|
327
|
+
const own = (state.enc ? encryptedStateKey : stateKey)(state.path, stat.size, stat.mtimeMs)
|
|
328
|
+
const other = (state.enc ? stateKey : encryptedStateKey)(state.path, stat.size, stat.mtimeMs)
|
|
329
|
+
|
|
330
|
+
if (key === other) return { ok: false, reason: 'damaged' }
|
|
331
|
+
if (key !== own) return { ok: false, reason: 'changed' }
|
|
232
332
|
|
|
233
333
|
return { ok: true }
|
|
234
334
|
}
|