@teepin/sdk 0.2.1 → 0.3.0-rc.1
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 +41 -36
- package/dist/cjs/client.js +42 -4
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/direct/bytes.js +50 -0
- package/dist/cjs/direct/bytes.js.map +1 -0
- package/dist/cjs/direct/common.js +167 -0
- package/dist/cjs/direct/common.js.map +1 -0
- package/dist/cjs/direct/downloads-plan.js +127 -0
- package/dist/cjs/direct/downloads-plan.js.map +1 -0
- package/dist/cjs/direct/engine.js +405 -0
- package/dist/cjs/direct/engine.js.map +1 -0
- package/dist/cjs/direct/formats.js +44 -0
- package/dist/cjs/direct/formats.js.map +1 -0
- package/dist/cjs/direct/hpke.js +275 -0
- package/dist/cjs/direct/hpke.js.map +1 -0
- package/dist/cjs/direct/journal.js +80 -0
- package/dist/cjs/direct/journal.js.map +1 -0
- package/dist/cjs/direct/large.js +850 -0
- package/dist/cjs/direct/large.js.map +1 -0
- package/dist/cjs/direct/reads.js +1314 -0
- package/dist/cjs/direct/reads.js.map +1 -0
- package/dist/cjs/direct/sha256.js +122 -0
- package/dist/cjs/direct/sha256.js.map +1 -0
- package/dist/cjs/direct/tws1.js +480 -0
- package/dist/cjs/direct/tws1.js.map +1 -0
- package/dist/cjs/direct/uploads.js +1706 -0
- package/dist/cjs/direct/uploads.js.map +1 -0
- package/dist/cjs/errors.js +70 -13
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/links.js +195 -0
- package/dist/cjs/links.js.map +1 -0
- package/dist/cjs/node/downloads.js +835 -0
- package/dist/cjs/node/downloads.js.map +1 -0
- package/dist/cjs/node/journal.js +841 -0
- package/dist/cjs/node/journal.js.map +1 -0
- package/dist/cjs/node/uploads.js +219 -0
- package/dist/cjs/node/uploads.js.map +1 -0
- package/dist/cjs/node.js +26 -39
- package/dist/cjs/node.js.map +1 -1
- package/dist/cjs/storage.js +214 -184
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/transfer.js +18 -351
- package/dist/cjs/transfer.js.map +1 -1
- package/dist/esm/client.js +42 -4
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/direct/bytes.js +42 -0
- package/dist/esm/direct/bytes.js.map +1 -0
- package/dist/esm/direct/common.js +155 -0
- package/dist/esm/direct/common.js.map +1 -0
- package/dist/esm/direct/downloads-plan.js +117 -0
- package/dist/esm/direct/downloads-plan.js.map +1 -0
- package/dist/esm/direct/engine.js +385 -0
- package/dist/esm/direct/engine.js.map +1 -0
- package/dist/esm/direct/formats.js +40 -0
- package/dist/esm/direct/formats.js.map +1 -0
- package/dist/esm/direct/hpke.js +262 -0
- package/dist/esm/direct/hpke.js.map +1 -0
- package/dist/esm/direct/journal.js +75 -0
- package/dist/esm/direct/journal.js.map +1 -0
- package/dist/esm/direct/large.js +816 -0
- package/dist/esm/direct/large.js.map +1 -0
- package/dist/esm/direct/reads.js +1275 -0
- package/dist/esm/direct/reads.js.map +1 -0
- package/dist/esm/direct/sha256.js +118 -0
- package/dist/esm/direct/sha256.js.map +1 -0
- package/dist/esm/direct/tws1.js +460 -0
- package/dist/esm/direct/tws1.js.map +1 -0
- package/dist/esm/direct/uploads.js +1660 -0
- package/dist/esm/direct/uploads.js.map +1 -0
- package/dist/esm/errors.js +64 -11
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/links.js +189 -0
- package/dist/esm/links.js.map +1 -0
- package/dist/esm/node/downloads.js +829 -0
- package/dist/esm/node/downloads.js.map +1 -0
- package/dist/esm/node/journal.js +824 -0
- package/dist/esm/node/journal.js.map +1 -0
- package/dist/esm/node/uploads.js +187 -0
- package/dist/esm/node/uploads.js.map +1 -0
- package/dist/esm/node.js +17 -14
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/storage.js +214 -184
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/transfer.js +19 -349
- package/dist/esm/transfer.js.map +1 -1
- package/dist/types/client.d.ts +45 -4
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/direct/bytes.d.ts +11 -0
- package/dist/types/direct/bytes.d.ts.map +1 -0
- package/dist/types/direct/common.d.ts +47 -0
- package/dist/types/direct/common.d.ts.map +1 -0
- package/dist/types/direct/downloads-plan.d.ts +75 -0
- package/dist/types/direct/downloads-plan.d.ts.map +1 -0
- package/dist/types/direct/engine.d.ts +191 -0
- package/dist/types/direct/engine.d.ts.map +1 -0
- package/dist/types/direct/formats.d.ts +9 -0
- package/dist/types/direct/formats.d.ts.map +1 -0
- package/dist/types/direct/hpke.d.ts +55 -0
- package/dist/types/direct/hpke.d.ts.map +1 -0
- package/dist/types/direct/journal.d.ts +55 -0
- package/dist/types/direct/journal.d.ts.map +1 -0
- package/dist/types/direct/large.d.ts +170 -0
- package/dist/types/direct/large.d.ts.map +1 -0
- package/dist/types/direct/reads.d.ts +306 -0
- package/dist/types/direct/reads.d.ts.map +1 -0
- package/dist/types/direct/sha256.d.ts +16 -0
- package/dist/types/direct/sha256.d.ts.map +1 -0
- package/dist/types/direct/tws1.d.ts +157 -0
- package/dist/types/direct/tws1.d.ts.map +1 -0
- package/dist/types/direct/uploads.d.ts +461 -0
- package/dist/types/direct/uploads.d.ts.map +1 -0
- package/dist/types/errors.d.ts +47 -8
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/links.d.ts +86 -0
- package/dist/types/links.d.ts.map +1 -0
- package/dist/types/node/downloads.d.ts +73 -0
- package/dist/types/node/downloads.d.ts.map +1 -0
- package/dist/types/node/journal.d.ts +162 -0
- package/dist/types/node/journal.d.ts.map +1 -0
- package/dist/types/node/uploads.d.ts +86 -0
- package/dist/types/node/uploads.d.ts.map +1 -0
- package/dist/types/node.d.ts +4 -2
- package/dist/types/node.d.ts.map +1 -1
- package/dist/types/storage.d.ts +107 -64
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/transfer.d.ts +14 -23
- package/dist/types/transfer.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright 2026 TEEPIN Project
|
|
3
|
+
// Licensed under the Apache License, Version 2.0
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.caseInsensitive = exports.downloadPrefix = exports.downloadFiles = exports.testHooks = void 0;
|
|
6
|
+
// Batch downloads to a folder: downloadFiles and downloadPrefix (TEEPIN-STORAGE-DESIGN.md sections 19.15, 22.5 and
|
|
7
|
+
// 24.4), a port of teepin-python's _downloads.py with the same rules.
|
|
8
|
+
//
|
|
9
|
+
// Read sessions are signed 1,000 objects to a request (objects/read), and a large object's later windows one at a
|
|
10
|
+
// time. The work is a pool of pieces, one per object or per window of a large object, concurrency at a time:
|
|
11
|
+
//
|
|
12
|
+
// - a piece's bytes are verified as they arrive and written into <name>.teepin-part beside the target, fsynced, then
|
|
13
|
+
// journaled;
|
|
14
|
+
// - an object whose pieces are all written is checked against its SHA-256 (read back from the part file), then renamed
|
|
15
|
+
// into place, so a target is never half written;
|
|
16
|
+
// - a run stopped by a passing error (the network, storage, a revoked key), by an abort, or by the process ending keeps
|
|
17
|
+
// its journal and part files: running the same download again goes on from the written pieces of each object whose
|
|
18
|
+
// version hasn't changed.
|
|
19
|
+
//
|
|
20
|
+
// skipExisting decides from the object's details before anything is signed (signatures are metered): the file there
|
|
21
|
+
// must have the object's size and plaintext SHA-256, never its ETag.
|
|
22
|
+
//
|
|
23
|
+
// Paths (src/direct/downloads-plan.ts has the rules on names): no link under the destination may carry a write out of
|
|
24
|
+
// it. That's checked before anything is signed, and again before a part file is made and before it's renamed. A part
|
|
25
|
+
// file is only ever new (never a name already there, which could be a link into another file) or one an earlier run
|
|
26
|
+
// made with no other name, and it's written only through a handle that is still that file, so a link or a file put in
|
|
27
|
+
// its place later is never written through.
|
|
28
|
+
//
|
|
29
|
+
// The journal is the state directory's downloads/<run-id>/: run.json, events.jsonl (appended, each event fsynced before
|
|
30
|
+
// the action that needs it) and lock (made with O_EXCL, holding the host and process id; a lock whose process is gone
|
|
31
|
+
// is taken over). A run adopts the unfinished objects of earlier runs at its paths, and what's left of an earlier run
|
|
32
|
+
// is rewritten whole (a temporary file renamed over it), or the run removed once nothing is left.
|
|
33
|
+
const node_crypto_1 = require("node:crypto");
|
|
34
|
+
const node_fs_1 = require("node:fs");
|
|
35
|
+
const promises_1 = require("node:fs/promises");
|
|
36
|
+
const node_os_1 = require("node:os");
|
|
37
|
+
const node_path_1 = require("node:path");
|
|
38
|
+
const reads_js_1 = require("../direct/reads.js");
|
|
39
|
+
const downloads_plan_js_1 = require("../direct/downloads-plan.js");
|
|
40
|
+
const errors_js_1 = require("../errors.js");
|
|
41
|
+
const journal_js_1 = require("./journal.js");
|
|
42
|
+
const transfer_js_1 = require("../transfer.js");
|
|
43
|
+
/** For tests only (a test checks that nothing under src sets it): called once each journal write is durable. */
|
|
44
|
+
exports.testHooks = {};
|
|
45
|
+
const rangeId = (r) => `${r[0]}-${r[1]}`;
|
|
46
|
+
const codeOf = (err) => (err instanceof Error && 'code' in err ? String(err.code) : '');
|
|
47
|
+
const missing = (err) => codeOf(err) === 'ENOENT' || codeOf(err) === 'ENOTDIR';
|
|
48
|
+
/** O_NOFOLLOW where the OS has it (not Windows: there, a handle's identity check stops a link). */
|
|
49
|
+
const NOFOLLOW = node_fs_1.constants.O_NOFOLLOW ?? 0;
|
|
50
|
+
const READ_CHUNK = 2 ** 20;
|
|
51
|
+
/**
|
|
52
|
+
* Downloads many objects, each to destDir joined with its key (a/b.jpg to destDir/a/b.jpg), and returns one
|
|
53
|
+
* DownloadResult per key, in order (a key given twice is one). One object's failure never fails another.
|
|
54
|
+
*
|
|
55
|
+
* Read sessions are signed 1,000 objects to a request. Up to concurrency objects, or windows of a large object, are
|
|
56
|
+
* fetched at once, each verified before it is written to <name>.teepin-part beside its target; an object is checked
|
|
57
|
+
* against its SHA-256 and renamed into place only when whole.
|
|
58
|
+
*
|
|
59
|
+
* A download stopped by a passing error (the network, the storage network unavailable, a revoked key), by an abort or
|
|
60
|
+
* by the process ending is pending: its part file and the run's journal stay, and running the same download again
|
|
61
|
+
* goes on from what was written, unless the object has changed since.
|
|
62
|
+
*
|
|
63
|
+
* A key that isn't a safe path under destDir fails (UnsafePathError): "..", an absolute path, a name Windows can't
|
|
64
|
+
* have (on every OS), a link under destDir leading out of it, or two keys for one file (names that differ only by case,
|
|
65
|
+
* where names ignore case). A key ending in "/" (a folder marker) is skipped.
|
|
66
|
+
*/
|
|
67
|
+
function downloadFiles(storage, bucket, keys, destDir, opts = {}) {
|
|
68
|
+
return run(storage, bucket, keys.map((k) => [k, k]), destDir, new Map(), opts);
|
|
69
|
+
}
|
|
70
|
+
exports.downloadFiles = downloadFiles;
|
|
71
|
+
/**
|
|
72
|
+
* Downloads every object under prefix (all of them for ""), each to destDir joined with its key after the prefix, as
|
|
73
|
+
* downloadFiles does. With skipExisting, the listing's size and SHA-256 decide, so a file already there costs nothing
|
|
74
|
+
* more.
|
|
75
|
+
*/
|
|
76
|
+
async function downloadPrefix(storage, bucket, prefix, destDir, opts = {}) {
|
|
77
|
+
const objs = [];
|
|
78
|
+
try {
|
|
79
|
+
opts.signal?.throwIfAborted();
|
|
80
|
+
for await (const o of storage.objects(bucket, prefix, opts.signal))
|
|
81
|
+
objs.push(o);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (opts.signal?.aborted)
|
|
85
|
+
throw opts.signal.reason;
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
const pairs = objs.map((o) => [o.key, o.key.slice(prefix.length).replace(/^\/+/, '')]);
|
|
89
|
+
return run(storage, bucket, pairs, destDir, new Map(objs.map((o) => [o.key, o])), opts);
|
|
90
|
+
}
|
|
91
|
+
exports.downloadPrefix = downloadPrefix;
|
|
92
|
+
async function run(storage, bucket, pairs, destDir, details, opts) {
|
|
93
|
+
const signal = opts.signal;
|
|
94
|
+
let batch;
|
|
95
|
+
try {
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
const root = (0, node_path_1.resolve)(destDir);
|
|
98
|
+
const planned = await (0, downloads_plan_js_1.plan)(pairs, {
|
|
99
|
+
windows: process.platform === 'win32',
|
|
100
|
+
insensitive: await caseInsensitive(root),
|
|
101
|
+
target: (parts, rel) => targetPath(root, parts, rel),
|
|
102
|
+
shown: (rel) => root + node_path_1.sep + rel,
|
|
103
|
+
});
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
batch = new BatchDownload(storage, bucket, root, details, opts);
|
|
106
|
+
await batch.go(planned.entries.filter((e) => !e.result));
|
|
107
|
+
return batch.output(planned.entries, planned.collisions);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (signal?.aborted)
|
|
111
|
+
throw signal.reason; // the abort's reason wins, whatever it interrupted
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await batch?.end();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** One object being downloaded to its path. */
|
|
119
|
+
class Target {
|
|
120
|
+
entry;
|
|
121
|
+
s;
|
|
122
|
+
dek;
|
|
123
|
+
ranges;
|
|
124
|
+
part;
|
|
125
|
+
done = new Set();
|
|
126
|
+
/** The part file, once made or adopted. */
|
|
127
|
+
id;
|
|
128
|
+
error;
|
|
129
|
+
resumed = false;
|
|
130
|
+
saved = false;
|
|
131
|
+
constructor(entry, s,
|
|
132
|
+
/** This download's own copy of the data key, zeroed when the run ends. */
|
|
133
|
+
dek, ranges) {
|
|
134
|
+
this.entry = entry;
|
|
135
|
+
this.s = s;
|
|
136
|
+
this.dek = dek;
|
|
137
|
+
this.ranges = ranges;
|
|
138
|
+
this.part = entry.path + downloads_plan_js_1.PART_SUFFIX;
|
|
139
|
+
}
|
|
140
|
+
get key() {
|
|
141
|
+
return this.entry.key;
|
|
142
|
+
}
|
|
143
|
+
get path() {
|
|
144
|
+
return this.entry.path;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
class Progress {
|
|
148
|
+
total;
|
|
149
|
+
fn;
|
|
150
|
+
done = 0;
|
|
151
|
+
constructor(total, fn) {
|
|
152
|
+
this.total = total;
|
|
153
|
+
this.fn = fn;
|
|
154
|
+
}
|
|
155
|
+
add(n) {
|
|
156
|
+
if (!n)
|
|
157
|
+
return;
|
|
158
|
+
this.done += n;
|
|
159
|
+
this.fn?.(this.done, this.total);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** One downloadFiles or downloadPrefix call. */
|
|
163
|
+
class BatchDownload {
|
|
164
|
+
storage;
|
|
165
|
+
bucket;
|
|
166
|
+
root;
|
|
167
|
+
details;
|
|
168
|
+
opts;
|
|
169
|
+
reads;
|
|
170
|
+
stateDir;
|
|
171
|
+
signal;
|
|
172
|
+
concurrency;
|
|
173
|
+
results = new Map();
|
|
174
|
+
targets = [];
|
|
175
|
+
journal = null;
|
|
176
|
+
prog = new Progress(0);
|
|
177
|
+
/** The error that stopped the run (a revoked key, say): what's left stays pending. */
|
|
178
|
+
stopError;
|
|
179
|
+
/** The run went to its end (not stopped by an abort or an error thrown out of it). */
|
|
180
|
+
ended = false;
|
|
181
|
+
constructor(storage, bucket, root, details, opts) {
|
|
182
|
+
this.storage = storage;
|
|
183
|
+
this.bucket = bucket;
|
|
184
|
+
this.root = root;
|
|
185
|
+
this.details = details;
|
|
186
|
+
this.opts = opts;
|
|
187
|
+
this.reads = storage['reads']; // the storage service's own, so read sessions share its key cache
|
|
188
|
+
this.stateDir = storage.client.stateDir || defaultStateDir();
|
|
189
|
+
this.signal = opts.signal;
|
|
190
|
+
this.concurrency = opts.concurrency && opts.concurrency > 0 ? Math.floor(opts.concurrency) : transfer_js_1.DEFAULT_CONCURRENCY;
|
|
191
|
+
}
|
|
192
|
+
async go(entries) {
|
|
193
|
+
if (this.opts.journal ?? true)
|
|
194
|
+
this.journal = await RunJournal.create(this.stateDir, this.bucket);
|
|
195
|
+
const todo = await this.skip(entries);
|
|
196
|
+
const adopted = this.journal ? await this.adopt(new Set(todo.map((e) => e.path))) : new Map();
|
|
197
|
+
let sessions = new Map();
|
|
198
|
+
try {
|
|
199
|
+
if (todo.length)
|
|
200
|
+
sessions = await this.reads.sessions(this.bucket, todo.map((e) => e.key), this.signal);
|
|
201
|
+
for (const entry of todo) {
|
|
202
|
+
// Every key has a session or its error (an object still in the old stage: stagedError).
|
|
203
|
+
const s = sessions.get(entry.key);
|
|
204
|
+
if (s instanceof reads_js_1.Session)
|
|
205
|
+
await this.target(entry, s, adopted.get(entry.path));
|
|
206
|
+
else
|
|
207
|
+
this.results.set(entry, { key: entry.key, path: entry.path, status: 'failed', error: s, resumed: false });
|
|
208
|
+
}
|
|
209
|
+
await this.parts();
|
|
210
|
+
await this.pieces();
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
for (const t of this.targets)
|
|
214
|
+
t.dek?.fill(0);
|
|
215
|
+
for (const s of sessions.values())
|
|
216
|
+
if (s instanceof reads_js_1.Session)
|
|
217
|
+
s.forget();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
output(entries, collisions) {
|
|
221
|
+
const out = entries.map((e) => {
|
|
222
|
+
const r = e.result ?? this.results.get(e);
|
|
223
|
+
if (!r)
|
|
224
|
+
throw new errors_js_1.TeepinError(`${e.key}: the download ended without a result`);
|
|
225
|
+
return r;
|
|
226
|
+
});
|
|
227
|
+
this.ended = true;
|
|
228
|
+
return [...out, ...collisions];
|
|
229
|
+
}
|
|
230
|
+
/** Closes the run, always: removed when nothing is left to go on with, else kept (unlocked) for the next run. */
|
|
231
|
+
async end() {
|
|
232
|
+
const run = this.journal;
|
|
233
|
+
if (!run)
|
|
234
|
+
return;
|
|
235
|
+
const pending = [...this.results.values()].some((r) => r.status === 'pending');
|
|
236
|
+
if (pending || !this.ended)
|
|
237
|
+
await run.release();
|
|
238
|
+
else
|
|
239
|
+
await run.remove().catch(() => run.release()); // a run that can't be removed is removed by the next one
|
|
240
|
+
}
|
|
241
|
+
/** What's left after skipExisting, decided before anything is signed. */
|
|
242
|
+
async skip(entries) {
|
|
243
|
+
if (!this.opts.skipExisting)
|
|
244
|
+
return [...entries];
|
|
245
|
+
const todo = [];
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (await isFile(entry.path)) {
|
|
248
|
+
let obj = this.details.get(entry.key);
|
|
249
|
+
if (!obj) {
|
|
250
|
+
try {
|
|
251
|
+
obj = await this.storage.statObject(this.bucket, entry.key, this.signal);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (!(err instanceof errors_js_1.NotFoundError))
|
|
255
|
+
throw err;
|
|
256
|
+
this.results.set(entry, { key: entry.key, path: entry.path, status: 'failed', error: err, resumed: false });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (await sameFile(entry.path, obj, this.signal)) {
|
|
261
|
+
this.results.set(entry, { key: entry.key, path: entry.path, status: 'skipped', object: obj, resumed: false });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
todo.push(entry);
|
|
266
|
+
}
|
|
267
|
+
return todo;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The object's target: its path checked again, its folder made, and its earlier run's part file adopted when it's
|
|
271
|
+
* still this version's and only this run's to write. Nothing is written yet.
|
|
272
|
+
*/
|
|
273
|
+
async target(entry, s, prior) {
|
|
274
|
+
try {
|
|
275
|
+
await checkInside(this.root, entry.path); // again, just before anything is written: links can appear
|
|
276
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(entry.path), { recursive: true });
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
s.forget();
|
|
280
|
+
this.results.set(entry, { key: entry.key, path: entry.path, status: 'failed', error: err, resumed: false });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const dek = s.dek ? s.dek.slice() : null;
|
|
284
|
+
s.forget();
|
|
285
|
+
const t = new Target(entry, s, dek, this.reads.ranges(s, 0));
|
|
286
|
+
if (prior && prior.version === s.version && prior.size === s.size) {
|
|
287
|
+
t.id = await ownFile(t.part, s.size);
|
|
288
|
+
if (t.id) {
|
|
289
|
+
for (const r of t.ranges)
|
|
290
|
+
if (prior.ranges.has(rangeId(r)))
|
|
291
|
+
t.done.add(rangeId(r));
|
|
292
|
+
t.resumed = t.done.size > 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.targets.push(t);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Journals every target, then makes the part files of those not adopted. Each is new, never a name already there:
|
|
299
|
+
* writing into an old name could write through a link (hard or symbolic) into another file.
|
|
300
|
+
*/
|
|
301
|
+
async parts() {
|
|
302
|
+
await this.journal?.append(this.targets.flatMap((t) => [
|
|
303
|
+
{ e: 'object', path: t.path, key: t.key, version: t.s.version, size: t.s.size },
|
|
304
|
+
...t.ranges.filter((r) => t.done.has(rangeId(r))).map((r) => ({ e: 'range', path: t.path, a: r[0], b: r[1] })),
|
|
305
|
+
]));
|
|
306
|
+
for (const t of [...this.targets]) {
|
|
307
|
+
if (t.id)
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
await (0, promises_1.rm)(t.part, { force: true });
|
|
311
|
+
const fh = await (0, promises_1.open)(t.part, 'wx');
|
|
312
|
+
try {
|
|
313
|
+
await fh.truncate(t.s.size);
|
|
314
|
+
t.id = identity(await fh.stat({ bigint: true }));
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
await fh.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
this.targets.splice(this.targets.indexOf(t), 1);
|
|
322
|
+
t.dek?.fill(0);
|
|
323
|
+
this.results.set(t.entry, { key: t.key, path: t.path, status: 'failed', error: err, resumed: false });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async pieces() {
|
|
328
|
+
const targets = this.targets;
|
|
329
|
+
this.prog = new Progress(targets.reduce((n, t) => n + t.s.size, 0), this.opts.onProgress);
|
|
330
|
+
for (const t of targets)
|
|
331
|
+
this.prog.add(t.ranges.reduce((n, r) => n + (t.done.has(rangeId(r)) ? r[1] - r[0] : 0), 0));
|
|
332
|
+
const work = [];
|
|
333
|
+
for (const t of targets) {
|
|
334
|
+
if (t.done.size === t.ranges.length)
|
|
335
|
+
work.push([t, null]); // every piece written before a stop: finish it
|
|
336
|
+
for (const r of t.ranges)
|
|
337
|
+
if (!t.done.has(rangeId(r)))
|
|
338
|
+
work.push([t, r]);
|
|
339
|
+
}
|
|
340
|
+
await pool(this.concurrency, work, this.signal, ([t, r]) => this.piece(t, r));
|
|
341
|
+
for (const t of targets)
|
|
342
|
+
this.results.set(t.entry, await this.result(t));
|
|
343
|
+
}
|
|
344
|
+
/** One piece: never throws, its error is recorded on its object (an abort ends the pool). */
|
|
345
|
+
async piece(t, r) {
|
|
346
|
+
if (this.stopError !== undefined || t.error !== undefined || this.signal?.aborted)
|
|
347
|
+
return;
|
|
348
|
+
try {
|
|
349
|
+
if (r) {
|
|
350
|
+
await this.write(t, r);
|
|
351
|
+
await this.journal?.append([{ e: 'range', path: t.path, a: r[0], b: r[1] }]);
|
|
352
|
+
t.done.add(rangeId(r));
|
|
353
|
+
if (t.done.size < t.ranges.length)
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.finish(t);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
if (t.error === undefined)
|
|
360
|
+
t.error = err;
|
|
361
|
+
if ((0, downloads_plan_js_1.stopsRun)(err))
|
|
362
|
+
this.stopError ??= err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** The object's bytes [a, b), each verified before it's written, then fsynced. */
|
|
366
|
+
async write(t, [a, b]) {
|
|
367
|
+
const fh = await openOwn(t, true);
|
|
368
|
+
try {
|
|
369
|
+
let pos = a;
|
|
370
|
+
for await (const c of this.reads.plaintext(t.s, t.dek, a, b, t.ranges.length === 1, this.signal)) {
|
|
371
|
+
await writeAt(fh, c, pos);
|
|
372
|
+
pos += c.length;
|
|
373
|
+
this.prog.add(c.length);
|
|
374
|
+
}
|
|
375
|
+
if (pos !== b)
|
|
376
|
+
throw new errors_js_1.DataIntegrityError(`${t.key}: ${pos - a} bytes of the ${b - a} from ${a}`, 'truncated');
|
|
377
|
+
await fh.sync();
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
await fh.close();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Every piece written: checked whole, then renamed into place, with the object's upload time. */
|
|
384
|
+
async finish(t) {
|
|
385
|
+
if (t.ranges.length > 1 && t.s.checkable) {
|
|
386
|
+
const fh = await openOwn(t, false);
|
|
387
|
+
let got;
|
|
388
|
+
try {
|
|
389
|
+
got = await sha256Of(fh, this.signal);
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
await fh.close();
|
|
393
|
+
}
|
|
394
|
+
if (got !== t.s.sha256)
|
|
395
|
+
throw new errors_js_1.ChecksumMismatchError(`${t.key}: received ${got}, stored ${t.s.sha256}`);
|
|
396
|
+
}
|
|
397
|
+
await checkInside(this.root, t.path); // and again before the rename
|
|
398
|
+
await checkOwn(t);
|
|
399
|
+
await (0, journal_js_1.renameOver)(t.part, t.path); // on Windows, waits a moment for another program to let go of the target
|
|
400
|
+
t.saved = true;
|
|
401
|
+
const when = t.s.uploadedAt;
|
|
402
|
+
if (when)
|
|
403
|
+
await (0, promises_1.lutimes)(t.path, when, when).catch(() => undefined);
|
|
404
|
+
await this.journal?.append([{ e: 'saved', path: t.path }]);
|
|
405
|
+
}
|
|
406
|
+
async result(t) {
|
|
407
|
+
const base = { key: t.key, path: t.path, resumed: t.resumed };
|
|
408
|
+
if (t.saved)
|
|
409
|
+
return { ...base, status: 'saved', object: t.s.object() };
|
|
410
|
+
const error = t.error ?? this.stopError;
|
|
411
|
+
if (error === undefined || (0, downloads_plan_js_1.passing)(error))
|
|
412
|
+
return { ...base, status: 'pending', error };
|
|
413
|
+
// Final: nothing to go on from. The part file is removed only while no link leads its path out of the destination.
|
|
414
|
+
await checkInside(this.root, t.path)
|
|
415
|
+
.then(() => (0, promises_1.rm)(t.part, { force: true }))
|
|
416
|
+
.catch(() => undefined);
|
|
417
|
+
await this.journal?.append([{ e: 'failed', path: t.path, error: String(error) }]).catch(() => undefined);
|
|
418
|
+
return { ...base, status: 'failed', error };
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Earlier runs' unfinished objects at these paths: their version, size and written pieces, moved into this run's
|
|
422
|
+
* journal. Each is taken out of its run's journal first, so no two journals ever claim one part file's pieces (a stop
|
|
423
|
+
* in between loses them, and the object starts again). A run left with nothing unfinished is removed.
|
|
424
|
+
*/
|
|
425
|
+
async adopt(paths) {
|
|
426
|
+
const out = new Map();
|
|
427
|
+
const own = this.journal;
|
|
428
|
+
if (!own)
|
|
429
|
+
return out;
|
|
430
|
+
for (const id of await runIds(this.stateDir)) {
|
|
431
|
+
if (id === own.id)
|
|
432
|
+
continue;
|
|
433
|
+
const other = new RunJournal(this.stateDir, id);
|
|
434
|
+
if (!(await takeLock(other.lock)))
|
|
435
|
+
continue; // another process is working on it
|
|
436
|
+
const taken = [];
|
|
437
|
+
try {
|
|
438
|
+
const entries = replay(await (0, promises_1.readFile)(other.events, 'utf8').catch((err) => (missing(err) ? '' : Promise.reject(err))));
|
|
439
|
+
const left = [];
|
|
440
|
+
for (const [path, e] of entries) {
|
|
441
|
+
if (e.state !== 'open')
|
|
442
|
+
continue;
|
|
443
|
+
if (paths.has(path) && !out.has(path))
|
|
444
|
+
taken.push([path, e]);
|
|
445
|
+
else
|
|
446
|
+
left.push([path, e]);
|
|
447
|
+
}
|
|
448
|
+
if (!left.length) {
|
|
449
|
+
await other.remove();
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
if (taken.length)
|
|
453
|
+
await (0, journal_js_1.writeAtomic)(other.events, left.flatMap(([path, e]) => eventsOf(path, e)).map(line).join(''));
|
|
454
|
+
await releaseLock(other.lock);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
await releaseLock(other.lock); // unreadable, or not rewritten: left as it was, nothing taken
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
for (const [path, e] of taken)
|
|
462
|
+
out.set(path, { version: e.version, size: e.size, ranges: e.ranges });
|
|
463
|
+
await own.append(taken.flatMap(([path, e]) => eventsOf(path, e)));
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Runs fn for each item, at most n at once, until signal aborts; fn never throws. Every worker is waited for, so nothing
|
|
470
|
+
* is left running; then an abort throws its reason.
|
|
471
|
+
*/
|
|
472
|
+
async function pool(n, items, signal, fn) {
|
|
473
|
+
let next = 0;
|
|
474
|
+
const worker = async () => {
|
|
475
|
+
while (next < items.length && !signal?.aborted)
|
|
476
|
+
await fn(items[next++]);
|
|
477
|
+
};
|
|
478
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, worker));
|
|
479
|
+
signal?.throwIfAborted();
|
|
480
|
+
}
|
|
481
|
+
// --- files ----------------------------------------------------------------------------
|
|
482
|
+
async function writeAt(fh, data, pos) {
|
|
483
|
+
for (let done = 0; done < data.length;)
|
|
484
|
+
done += (await fh.write(data, done, data.length - done, pos + done)).bytesWritten;
|
|
485
|
+
}
|
|
486
|
+
async function sha256Of(fh, signal) {
|
|
487
|
+
const h = (0, node_crypto_1.createHash)('sha256');
|
|
488
|
+
const buf = new Uint8Array(READ_CHUNK);
|
|
489
|
+
for (let pos = 0;;) {
|
|
490
|
+
signal?.throwIfAborted();
|
|
491
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
|
|
492
|
+
if (!bytesRead)
|
|
493
|
+
return h.digest('hex');
|
|
494
|
+
h.update(buf.subarray(0, bytesRead));
|
|
495
|
+
pos += bytesRead;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function sha256File(path, signal) {
|
|
499
|
+
const fh = await (0, promises_1.open)(path, 'r');
|
|
500
|
+
try {
|
|
501
|
+
return await sha256Of(fh, signal);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
await fh.close();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const isFile = (path) => (0, promises_1.stat)(path).then((st) => st.isFile(), () => false);
|
|
508
|
+
/**
|
|
509
|
+
* Whether the file at path already is the object: its size and plaintext SHA-256 (never the ETag). An object whose
|
|
510
|
+
* checksum is a composite of parts can't be compared, so it's never the same.
|
|
511
|
+
*/
|
|
512
|
+
async function sameFile(path, obj, signal) {
|
|
513
|
+
const st = await (0, promises_1.stat)(path).catch(() => null);
|
|
514
|
+
if (!st || st.size !== obj.size)
|
|
515
|
+
return false;
|
|
516
|
+
if (!obj.checksumSha256 || obj.etag.includes('-'))
|
|
517
|
+
return false;
|
|
518
|
+
return (await sha256File(path, signal)) === obj.checksumSha256;
|
|
519
|
+
}
|
|
520
|
+
const identity = (st) => ({ dev: st.dev, ino: st.ino });
|
|
521
|
+
/** The file at path when it's a regular file of size bytes with no other name (not a link, nor hard-linked): its identity. */
|
|
522
|
+
async function ownFile(path, size) {
|
|
523
|
+
const st = await (0, promises_1.lstat)(path, { bigint: true }).catch(() => null);
|
|
524
|
+
return st && st.isFile() && st.nlink === 1n && st.size === BigInt(size) ? identity(st) : undefined;
|
|
525
|
+
}
|
|
526
|
+
/** UnsafePathError unless the target's part file is still the one this run made or adopted, with no other name. */
|
|
527
|
+
async function checkOwn(t) {
|
|
528
|
+
const st = await (0, promises_1.lstat)(t.part, { bigint: true }).catch(() => null);
|
|
529
|
+
if (!st || !sameFileAs(st, t))
|
|
530
|
+
throw new downloads_plan_js_1.UnsafePathError(`${t.part} was replaced while it was written`);
|
|
531
|
+
}
|
|
532
|
+
function sameFileAs(st, t) {
|
|
533
|
+
return st.isFile() && st.nlink === 1n && t.id !== undefined && st.dev === t.id.dev && st.ino === t.id.ino;
|
|
534
|
+
}
|
|
535
|
+
/** The target's part file, opened (not through a link) and checked to still be the one this run made or adopted. */
|
|
536
|
+
async function openOwn(t, write) {
|
|
537
|
+
const fh = await (0, promises_1.open)(t.part, (write ? node_fs_1.constants.O_RDWR : node_fs_1.constants.O_RDONLY) | NOFOLLOW);
|
|
538
|
+
try {
|
|
539
|
+
if (!sameFileAs(await fh.stat({ bigint: true }), t))
|
|
540
|
+
throw new downloads_plan_js_1.UnsafePathError(`${t.part} was replaced while it was written`);
|
|
541
|
+
return fh;
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
await fh.close();
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// --- paths ----------------------------------------------------------------------------
|
|
549
|
+
/** Whether p is root or under it (both resolved). */
|
|
550
|
+
function within(root, p) {
|
|
551
|
+
const rel = (0, node_path_1.relative)(root, p);
|
|
552
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('..' + node_path_1.sep) && !(0, node_path_1.isAbsolute)(rel));
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Where a key's parts go under root: UnsafePathError when that isn't under it, or a link under it leads out of it.
|
|
556
|
+
*/
|
|
557
|
+
async function targetPath(root, parts, rel) {
|
|
558
|
+
const path = (0, node_path_1.resolve)(root, ...parts);
|
|
559
|
+
if (path === root || !within(root, path))
|
|
560
|
+
throw new downloads_plan_js_1.UnsafePathError(`${JSON.stringify(rel)} isn't a safe path under the destination`);
|
|
561
|
+
await checkInside(root, path);
|
|
562
|
+
return path;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* UnsafePathError unless path's folder, with every link resolved, is under root's, and its part file isn't a link: a
|
|
566
|
+
* link under the destination must never carry a write outside it.
|
|
567
|
+
*/
|
|
568
|
+
async function checkInside(root, path) {
|
|
569
|
+
const top = await realDir(root);
|
|
570
|
+
const real = await realDir((0, node_path_1.dirname)(path));
|
|
571
|
+
if (top === null || real === null || !within(top, real) || (await isLink(path + downloads_plan_js_1.PART_SUFFIX))) {
|
|
572
|
+
throw new downloads_plan_js_1.UnsafePathError(`${JSON.stringify(path)} isn't a safe path under the destination: a link leads out of it`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const isLink = (path) => (0, promises_1.lstat)(path).then((st) => st.isSymbolicLink(), () => false);
|
|
576
|
+
/**
|
|
577
|
+
* A folder's real path, every link resolved, as far as it exists; the rest as it is (folders a download would make).
|
|
578
|
+
* null when it can't be told: a link to nothing (making the folder would follow it), or a folder that can't be read.
|
|
579
|
+
*/
|
|
580
|
+
async function realDir(dir) {
|
|
581
|
+
const rest = [];
|
|
582
|
+
for (let cur = (0, node_path_1.resolve)(dir);;) {
|
|
583
|
+
try {
|
|
584
|
+
return (0, node_path_1.join)(await (0, promises_1.realpath)(cur), ...rest);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
if (!missing(err))
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
if (await (0, promises_1.lstat)(cur).then(() => true, () => false))
|
|
591
|
+
return null; // there, yet it leads nowhere
|
|
592
|
+
const up = (0, node_path_1.dirname)(cur);
|
|
593
|
+
if (up === cur)
|
|
594
|
+
return null;
|
|
595
|
+
rest.unshift((0, node_path_1.basename)(cur));
|
|
596
|
+
cur = up;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Whether names under dir ignore case (Windows, macOS's default volumes), found by trying; true when it can't be told,
|
|
601
|
+
* the safe side. dir is made if it isn't there.
|
|
602
|
+
*/
|
|
603
|
+
async function caseInsensitive(dir) {
|
|
604
|
+
try {
|
|
605
|
+
await (0, promises_1.mkdir)(dir, { recursive: true });
|
|
606
|
+
const name = `.teepin-case-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
|
|
607
|
+
const probe = (0, node_path_1.join)(dir, name);
|
|
608
|
+
await (await (0, promises_1.open)(probe, 'wx')).close();
|
|
609
|
+
try {
|
|
610
|
+
await (0, promises_1.stat)((0, node_path_1.join)(dir, name.toUpperCase()));
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
return !missing(err);
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
await (0, promises_1.rm)(probe, { force: true });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
exports.caseInsensitive = caseInsensitive;
|
|
625
|
+
// --- the journal ----------------------------------------------------------------------
|
|
626
|
+
/** The state directory for this OS (section 19.12), or TEEPIN_STATE_DIR. */
|
|
627
|
+
function defaultStateDir() {
|
|
628
|
+
const override = process.env.TEEPIN_STATE_DIR;
|
|
629
|
+
if (override)
|
|
630
|
+
return override;
|
|
631
|
+
if (process.platform === 'win32')
|
|
632
|
+
return (0, node_path_1.join)(process.env.LOCALAPPDATA || (0, node_path_1.join)((0, node_os_1.homedir)(), 'AppData', 'Local'), 'teepin');
|
|
633
|
+
if (process.platform === 'darwin')
|
|
634
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), 'Library', 'Application Support', 'teepin');
|
|
635
|
+
return (0, node_path_1.join)(process.env.XDG_STATE_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), '.local', 'state'), 'teepin');
|
|
636
|
+
}
|
|
637
|
+
/** A UUIDv7: it sorts by the time it was made. */
|
|
638
|
+
function uuid7() {
|
|
639
|
+
const b = (0, node_crypto_1.randomBytes)(16);
|
|
640
|
+
b.writeUIntBE(Date.now(), 0, 6);
|
|
641
|
+
b[6] = 0x70 | (b[6] & 0x0f);
|
|
642
|
+
b[8] = 0x80 | (b[8] & 0x3f);
|
|
643
|
+
const h = b.toString('hex');
|
|
644
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
645
|
+
}
|
|
646
|
+
const line = (e) => JSON.stringify(e) + '\n';
|
|
647
|
+
function eventsOf(path, e) {
|
|
648
|
+
return [
|
|
649
|
+
{ e: 'object', path, key: e.key, version: e.version, size: e.size },
|
|
650
|
+
...[...e.ranges.values()].map(([a, b]) => ({ e: 'range', path, a, b })),
|
|
651
|
+
];
|
|
652
|
+
}
|
|
653
|
+
/** One line of a journal as an event, or undefined for one that isn't (the last line, cut short by a crash). */
|
|
654
|
+
function parseEvent(text) {
|
|
655
|
+
let v;
|
|
656
|
+
try {
|
|
657
|
+
v = JSON.parse(text);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
if (typeof v !== 'object' || v === null || !('e' in v) || !('path' in v) || typeof v.path !== 'string')
|
|
663
|
+
return undefined;
|
|
664
|
+
const path = v.path;
|
|
665
|
+
const num = (x) => (typeof x === 'number' && Number.isSafeInteger(x) && x >= 0 ? x : undefined);
|
|
666
|
+
if (v.e === 'object' && 'key' in v && 'version' in v && 'size' in v) {
|
|
667
|
+
const size = num(v.size);
|
|
668
|
+
if (typeof v.key === 'string' && typeof v.version === 'string' && size !== undefined)
|
|
669
|
+
return { e: 'object', path, key: v.key, version: v.version, size };
|
|
670
|
+
}
|
|
671
|
+
else if (v.e === 'range' && 'a' in v && 'b' in v) {
|
|
672
|
+
const a = num(v.a);
|
|
673
|
+
const b = num(v.b);
|
|
674
|
+
if (a !== undefined && b !== undefined && a <= b)
|
|
675
|
+
return { e: 'range', path, a, b };
|
|
676
|
+
}
|
|
677
|
+
else if (v.e === 'saved') {
|
|
678
|
+
return { e: 'saved', path };
|
|
679
|
+
}
|
|
680
|
+
else if (v.e === 'failed') {
|
|
681
|
+
return { e: 'failed', path, error: 'error' in v ? String(v.error) : '' };
|
|
682
|
+
}
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
/** A journal's events, replayed: each path's download as last written. */
|
|
686
|
+
function replay(text) {
|
|
687
|
+
const out = new Map();
|
|
688
|
+
for (const raw of text.split('\n')) {
|
|
689
|
+
const ev = raw ? parseEvent(raw) : undefined;
|
|
690
|
+
if (!ev)
|
|
691
|
+
continue;
|
|
692
|
+
if (ev.e === 'object') {
|
|
693
|
+
out.set(ev.path, { key: ev.key, version: ev.version, size: ev.size, state: 'open', ranges: new Map() });
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
const e = out.get(ev.path);
|
|
697
|
+
if (!e)
|
|
698
|
+
continue;
|
|
699
|
+
if (ev.e === 'range')
|
|
700
|
+
e.ranges.set(rangeId([ev.a, ev.b]), [ev.a, ev.b]);
|
|
701
|
+
else
|
|
702
|
+
e.state = ev.e;
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
/** Lock files this process holds: a lock with this process's id and not here is left from an earlier process with it. */
|
|
707
|
+
const held = new Set();
|
|
708
|
+
/** Whether a lock file is left by a process that's gone (on this host), so it can be taken over. */
|
|
709
|
+
async function staleLock(path) {
|
|
710
|
+
let text;
|
|
711
|
+
let mtime;
|
|
712
|
+
try {
|
|
713
|
+
text = await (0, promises_1.readFile)(path, 'utf8');
|
|
714
|
+
mtime = (await (0, promises_1.stat)(path)).mtimeMs;
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
return missing(err); // gone meanwhile: try again
|
|
718
|
+
}
|
|
719
|
+
let owner;
|
|
720
|
+
try {
|
|
721
|
+
owner = JSON.parse(text);
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
owner = undefined;
|
|
725
|
+
}
|
|
726
|
+
if (typeof owner !== 'object' || owner === null || !('host' in owner) || !('pid' in owner) || typeof owner.pid !== 'number') {
|
|
727
|
+
return Date.now() - mtime > 60000; // half written: its maker may still be writing it
|
|
728
|
+
}
|
|
729
|
+
if (owner.host !== (0, node_os_1.hostname)())
|
|
730
|
+
return false; // another computer's (a shared folder): never taken
|
|
731
|
+
if (owner.pid === process.pid)
|
|
732
|
+
return !held.has(path);
|
|
733
|
+
try {
|
|
734
|
+
process.kill(owner.pid, 0);
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
return codeOf(err) !== 'EPERM'; // EPERM: alive, someone else's
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** Takes a run's lock: true when this process now holds it, false when a live process does. */
|
|
742
|
+
async function takeLock(path) {
|
|
743
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
744
|
+
try {
|
|
745
|
+
const fh = await (0, promises_1.open)(path, 'wx', 0o600);
|
|
746
|
+
try {
|
|
747
|
+
await fh.writeFile(JSON.stringify({ host: (0, node_os_1.hostname)(), pid: process.pid }));
|
|
748
|
+
}
|
|
749
|
+
finally {
|
|
750
|
+
await fh.close();
|
|
751
|
+
}
|
|
752
|
+
held.add(path);
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
if (codeOf(err) !== 'EEXIST')
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
if (!(await staleLock(path)))
|
|
760
|
+
return false;
|
|
761
|
+
await (0, promises_1.rm)(path, { force: true });
|
|
762
|
+
}
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
async function releaseLock(path) {
|
|
766
|
+
held.delete(path);
|
|
767
|
+
await (0, promises_1.rm)(path, { force: true }).catch(() => undefined);
|
|
768
|
+
}
|
|
769
|
+
/** The ids of the state directory's download runs, oldest first. */
|
|
770
|
+
async function runIds(stateDir) {
|
|
771
|
+
const root = (0, node_path_1.join)(stateDir, 'downloads');
|
|
772
|
+
const names = await (0, promises_1.readdir)(root).catch(() => []);
|
|
773
|
+
const out = [];
|
|
774
|
+
for (const n of names)
|
|
775
|
+
if (await isFile((0, node_path_1.join)(root, n, 'run.json')))
|
|
776
|
+
out.push(n);
|
|
777
|
+
return out.sort();
|
|
778
|
+
}
|
|
779
|
+
/** Makes path (and its parents) readable by its owner only; on Windows the profile folder's own ACL is relied on. */
|
|
780
|
+
async function privateDir(path) {
|
|
781
|
+
await (0, promises_1.mkdir)(path, { recursive: true, mode: 0o700 });
|
|
782
|
+
if (process.platform !== 'win32')
|
|
783
|
+
await (0, promises_1.chmod)(path, 0o700);
|
|
784
|
+
}
|
|
785
|
+
/** One download run's folder in the state directory: run.json, events.jsonl and its lock. */
|
|
786
|
+
class RunJournal {
|
|
787
|
+
id;
|
|
788
|
+
dir;
|
|
789
|
+
constructor(stateDir, id) {
|
|
790
|
+
this.id = id;
|
|
791
|
+
this.dir = (0, node_path_1.join)(stateDir, 'downloads', id);
|
|
792
|
+
}
|
|
793
|
+
get events() {
|
|
794
|
+
return (0, node_path_1.join)(this.dir, 'events.jsonl');
|
|
795
|
+
}
|
|
796
|
+
get lock() {
|
|
797
|
+
return (0, node_path_1.join)(this.dir, 'lock');
|
|
798
|
+
}
|
|
799
|
+
/** A new run, locked by this process. */
|
|
800
|
+
static async create(stateDir, bucket) {
|
|
801
|
+
await privateDir(stateDir);
|
|
802
|
+
await privateDir((0, node_path_1.join)(stateDir, 'downloads'));
|
|
803
|
+
const run = new RunJournal(stateDir, uuid7());
|
|
804
|
+
await (0, promises_1.mkdir)(run.dir, { mode: 0o700 });
|
|
805
|
+
if (!(await takeLock(run.lock)))
|
|
806
|
+
throw new errors_js_1.TeepinError(`download run ${run.id} is locked`);
|
|
807
|
+
await (0, journal_js_1.writeAtomic)((0, node_path_1.join)(run.dir, 'run.json'), JSON.stringify({ id: run.id, kind: 'downloads', bucket, created: new Date().toISOString() }) + '\n');
|
|
808
|
+
return run;
|
|
809
|
+
}
|
|
810
|
+
/** Appends events together and fsyncs them: durable when it resolves. */
|
|
811
|
+
async append(events) {
|
|
812
|
+
if (!events.length)
|
|
813
|
+
return;
|
|
814
|
+
const fh = await (0, promises_1.open)(this.events, 'a', 0o600);
|
|
815
|
+
try {
|
|
816
|
+
await fh.appendFile(events.map(line).join(''));
|
|
817
|
+
await fh.sync();
|
|
818
|
+
}
|
|
819
|
+
finally {
|
|
820
|
+
await fh.close();
|
|
821
|
+
}
|
|
822
|
+
exports.testHooks.journaled?.(events);
|
|
823
|
+
}
|
|
824
|
+
async release() {
|
|
825
|
+
await releaseLock(this.lock);
|
|
826
|
+
}
|
|
827
|
+
/** Deletes the run: its journal first (throwing if that can't be deleted, with the lock still held), its lock last. */
|
|
828
|
+
async remove() {
|
|
829
|
+
await (0, promises_1.rm)(this.events, { force: true });
|
|
830
|
+
await (0, promises_1.rm)((0, node_path_1.join)(this.dir, 'run.json'), { force: true }).catch(() => undefined);
|
|
831
|
+
await releaseLock(this.lock);
|
|
832
|
+
await (0, promises_1.rm)(this.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=downloads.js.map
|