swarm-deploy 0.0.0 → 0.2.0
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/CHANGELOG.md +76 -0
- package/LICENSE.md +201 -0
- package/NOTICE.md +13 -0
- package/README.md +1283 -0
- package/RELEASING.md +71 -0
- package/SECURITY.md +40 -0
- package/dist/abort.d.ts +16 -0
- package/dist/abort.js +52 -0
- package/dist/abort.js.map +1 -0
- package/dist/allowlist.d.ts +2 -0
- package/dist/allowlist.js +21 -0
- package/dist/allowlist.js.map +1 -0
- package/dist/bin/swarm-deploy.d.ts +2 -0
- package/dist/bin/swarm-deploy.js +41 -0
- package/dist/bin/swarm-deploy.js.map +1 -0
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +751 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +111 -0
- package/dist/client.js +276 -0
- package/dist/client.js.map +1 -0
- package/dist/direct-dht.d.ts +81 -0
- package/dist/direct-dht.js +254 -0
- package/dist/direct-dht.js.map +1 -0
- package/dist/error-code.d.ts +15 -0
- package/dist/error-code.js +30 -0
- package/dist/error-code.js.map +1 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/events-node.d.ts +2 -0
- package/dist/events-node.js +7 -0
- package/dist/events-node.js.map +1 -0
- package/dist/files.d.ts +32 -0
- package/dist/files.js +85 -0
- package/dist/files.js.map +1 -0
- package/dist/fs-node.d.ts +2 -0
- package/dist/fs-node.js +7 -0
- package/dist/fs-node.js.map +1 -0
- package/dist/hooks-module.d.ts +9 -0
- package/dist/hooks-module.js +118 -0
- package/dist/hooks-module.js.map +1 -0
- package/dist/hooks.d.ts +69 -0
- package/dist/hooks.js +88 -0
- package/dist/hooks.js.map +1 -0
- package/dist/identity.d.ts +7 -0
- package/dist/identity.js +50 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/os-node.d.ts +2 -0
- package/dist/os-node.js +7 -0
- package/dist/os-node.js.map +1 -0
- package/dist/path-node.d.ts +2 -0
- package/dist/path-node.js +7 -0
- package/dist/path-node.js.map +1 -0
- package/dist/process-node.d.ts +2 -0
- package/dist/process-node.js +7 -0
- package/dist/process-node.js.map +1 -0
- package/dist/release.d.ts +26 -0
- package/dist/release.js +419 -0
- package/dist/release.js.map +1 -0
- package/dist/server.d.ts +239 -0
- package/dist/server.js +928 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/atomic-file.d.ts +15 -0
- package/dist/storage/atomic-file.js +172 -0
- package/dist/storage/atomic-file.js.map +1 -0
- package/dist/storage/commit-journal.d.ts +120 -0
- package/dist/storage/commit-journal.js +417 -0
- package/dist/storage/commit-journal.js.map +1 -0
- package/dist/storage/commit-store.d.ts +307 -0
- package/dist/storage/commit-store.js +1691 -0
- package/dist/storage/commit-store.js.map +1 -0
- package/dist/storage/layout.d.ts +16 -0
- package/dist/storage/layout.js +409 -0
- package/dist/storage/layout.js.map +1 -0
- package/dist/storage/link-store.d.ts +70 -0
- package/dist/storage/link-store.js +363 -0
- package/dist/storage/link-store.js.map +1 -0
- package/dist/storage/recovery.d.ts +100 -0
- package/dist/storage/recovery.js +260 -0
- package/dist/storage/recovery.js.map +1 -0
- package/dist/storage/retention.d.ts +183 -0
- package/dist/storage/retention.js +619 -0
- package/dist/storage/retention.js.map +1 -0
- package/dist/storage/root-coordinator.d.ts +7 -0
- package/dist/storage/root-coordinator.js +27 -0
- package/dist/storage/root-coordinator.js.map +1 -0
- package/dist/storage/session-store.d.ts +2 -0
- package/dist/storage/session-store.js +7 -0
- package/dist/storage/session-store.js.map +1 -0
- package/dist/storage/tar-session-store.d.ts +147 -0
- package/dist/storage/tar-session-store.js +1044 -0
- package/dist/storage/tar-session-store.js.map +1 -0
- package/dist/storage/tree-fs.d.ts +44 -0
- package/dist/storage/tree-fs.js +580 -0
- package/dist/storage/tree-fs.js.map +1 -0
- package/dist/storage/tree-staging.d.ts +9 -0
- package/dist/storage/tree-staging.js +64 -0
- package/dist/storage/tree-staging.js.map +1 -0
- package/dist/storage/types.d.ts +77 -0
- package/dist/storage/types.js +3 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/symlinks.d.ts +36 -0
- package/dist/symlinks.js +128 -0
- package/dist/symlinks.js.map +1 -0
- package/dist/tar-protocol/controls.d.ts +73 -0
- package/dist/tar-protocol/controls.js +291 -0
- package/dist/tar-protocol/controls.js.map +1 -0
- package/dist/tar-protocol/direct-wire.d.ts +43 -0
- package/dist/tar-protocol/direct-wire.js +194 -0
- package/dist/tar-protocol/direct-wire.js.map +1 -0
- package/dist/tar-protocol/extract.d.ts +18 -0
- package/dist/tar-protocol/extract.js +124 -0
- package/dist/tar-protocol/extract.js.map +1 -0
- package/dist/tar-protocol/hash.d.ts +10 -0
- package/dist/tar-protocol/hash.js +44 -0
- package/dist/tar-protocol/hash.js.map +1 -0
- package/dist/tar-protocol/lifecycle.d.ts +13 -0
- package/dist/tar-protocol/lifecycle.js +90 -0
- package/dist/tar-protocol/lifecycle.js.map +1 -0
- package/dist/tar-protocol/manifest.d.ts +58 -0
- package/dist/tar-protocol/manifest.js +312 -0
- package/dist/tar-protocol/manifest.js.map +1 -0
- package/dist/tar-protocol/tree-extract.d.ts +37 -0
- package/dist/tar-protocol/tree-extract.js +240 -0
- package/dist/tar-protocol/tree-extract.js.map +1 -0
- package/dist/tar-protocol/tree-manifest.d.ts +45 -0
- package/dist/tar-protocol/tree-manifest.js +277 -0
- package/dist/tar-protocol/tree-manifest.js.map +1 -0
- package/dist/tar-protocol/tree-path.d.ts +15 -0
- package/dist/tar-protocol/tree-path.js +68 -0
- package/dist/tar-protocol/tree-path.js.map +1 -0
- package/dist/tar-protocol/tree.d.ts +68 -0
- package/dist/tar-protocol/tree.js +269 -0
- package/dist/tar-protocol/tree.js.map +1 -0
- package/dist/tar-protocol/ustar.d.ts +36 -0
- package/dist/tar-protocol/ustar.js +167 -0
- package/dist/tar-protocol/ustar.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +26 -0
- package/dist/validation.js.map +1 -0
- package/package.json +103 -1
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TarSessionStore = exports.TREE_SESSION_VERSION = void 0;
|
|
7
|
+
const b4a_1 = __importDefault(require("b4a"));
|
|
8
|
+
const error_code_js_1 = require("../error-code.js");
|
|
9
|
+
const _fs_1 = __importDefault(require("#fs"));
|
|
10
|
+
const _path_1 = __importDefault(require("#path"));
|
|
11
|
+
const sodium_native_1 = __importDefault(require("sodium-native"));
|
|
12
|
+
const errors_js_1 = require("../errors.js");
|
|
13
|
+
const controls_js_1 = require("../tar-protocol/controls.js");
|
|
14
|
+
const extract_js_1 = require("../tar-protocol/extract.js");
|
|
15
|
+
const tree_extract_js_1 = require("../tar-protocol/tree-extract.js");
|
|
16
|
+
const manifest_js_1 = require("../tar-protocol/manifest.js");
|
|
17
|
+
const tree_manifest_js_1 = require("../tar-protocol/tree-manifest.js");
|
|
18
|
+
const atomic_file_js_1 = require("./atomic-file.js");
|
|
19
|
+
const commit_journal_js_1 = require("./commit-journal.js");
|
|
20
|
+
const layout_js_1 = require("./layout.js");
|
|
21
|
+
const tree_staging_js_1 = require("./tree-staging.js");
|
|
22
|
+
const tree_fs_js_1 = require("./tree-fs.js");
|
|
23
|
+
const validation_js_1 = require("../validation.js");
|
|
24
|
+
const VERSION = 3;
|
|
25
|
+
const LEGACY_VERSION = 2;
|
|
26
|
+
/** Directory sessions are persisted at a version no file-only server understands. */
|
|
27
|
+
exports.TREE_SESSION_VERSION = 4;
|
|
28
|
+
const RECEIVING = 'receiving';
|
|
29
|
+
const VERIFIED = 'verified';
|
|
30
|
+
const DELETING = 'deleting';
|
|
31
|
+
const READ_BYTES = 64 * 1024;
|
|
32
|
+
function problem(message, cause = null) {
|
|
33
|
+
return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.PROTOCOL_INVALID, message, cause);
|
|
34
|
+
}
|
|
35
|
+
function hex(bytes) {
|
|
36
|
+
return b4a_1.default.toString(bytes, 'hex');
|
|
37
|
+
}
|
|
38
|
+
function bytes(value, name) {
|
|
39
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value))
|
|
40
|
+
throw problem(`Invalid ${name}`);
|
|
41
|
+
return b4a_1.default.from(value, 'hex');
|
|
42
|
+
}
|
|
43
|
+
function uint(value, name) {
|
|
44
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
45
|
+
throw problem(`Invalid ${name}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function key(value, name) {
|
|
49
|
+
if (!b4a_1.default.isBuffer(value) || value.byteLength !== 32)
|
|
50
|
+
throw problem(`Invalid ${name}`);
|
|
51
|
+
}
|
|
52
|
+
function sameMetadata(session, metadata, owner) {
|
|
53
|
+
if (!sodium_native_1.default.sodium_memcmp(session.ownerKey, owner) ||
|
|
54
|
+
session.name !== metadata.name ||
|
|
55
|
+
session.sourceParent !== metadata.sourceParent ||
|
|
56
|
+
session.tarSize !== metadata.tarSize ||
|
|
57
|
+
!sodium_native_1.default.sodium_memcmp(session.tarDigest, b4a_1.default.from(metadata.tarSha256, 'hex'))) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if ((0, controls_js_1.isTreeMetadata)(metadata)) {
|
|
61
|
+
return (session.kind === 'directory' &&
|
|
62
|
+
session.entryCount === metadata.entryCount &&
|
|
63
|
+
session.size === metadata.payloadBytes &&
|
|
64
|
+
sodium_native_1.default.sodium_memcmp(session.digest, b4a_1.default.from(metadata.treeSha256, 'hex')));
|
|
65
|
+
}
|
|
66
|
+
return (session.kind === 'file' &&
|
|
67
|
+
session.size === metadata.fileSize &&
|
|
68
|
+
sodium_native_1.default.sodium_memcmp(session.digest, b4a_1.default.from(metadata.fileSha256, 'hex')));
|
|
69
|
+
}
|
|
70
|
+
async function writeAll(handle, value, position) {
|
|
71
|
+
let offset = 0;
|
|
72
|
+
while (offset < value.byteLength) {
|
|
73
|
+
const result = await handle.write(value, offset, value.byteLength - offset, position + offset);
|
|
74
|
+
const written = typeof result === 'number' ? result : result.bytesWritten;
|
|
75
|
+
if (!Number.isSafeInteger(written) || written <= 0)
|
|
76
|
+
throw problem('Unable to write TAR staging');
|
|
77
|
+
offset += written;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function readAll(handle, size, position) {
|
|
81
|
+
const value = b4a_1.default.alloc(size);
|
|
82
|
+
let offset = 0;
|
|
83
|
+
while (offset < size) {
|
|
84
|
+
const result = await handle.read(value, offset, size - offset, position + offset);
|
|
85
|
+
const read = typeof result === 'number' ? result : result.bytesRead;
|
|
86
|
+
if (!Number.isSafeInteger(read) || read <= 0)
|
|
87
|
+
throw problem('Truncated TAR staging');
|
|
88
|
+
offset += read;
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
async function syncDirectory(directory, storage) {
|
|
93
|
+
const handle = await storage.open(directory, 'r');
|
|
94
|
+
try {
|
|
95
|
+
await handle.sync();
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await handle.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Durable direct-TAR session authority. */
|
|
102
|
+
class TarSessionStore {
|
|
103
|
+
layout;
|
|
104
|
+
maxStagingBytes;
|
|
105
|
+
minFreeBytes;
|
|
106
|
+
clock;
|
|
107
|
+
resumeTtl;
|
|
108
|
+
isSessionActive;
|
|
109
|
+
storage;
|
|
110
|
+
sessions = new Map();
|
|
111
|
+
reservedBytes = 0;
|
|
112
|
+
purgedSessions = 0;
|
|
113
|
+
/** Sessions dropped from memory whose staging files could not be unlinked. */
|
|
114
|
+
strandedSessions = 0;
|
|
115
|
+
initialized = false;
|
|
116
|
+
closed = false;
|
|
117
|
+
closing = false;
|
|
118
|
+
pending = Promise.resolve();
|
|
119
|
+
closePromise = null;
|
|
120
|
+
verifying = new Set();
|
|
121
|
+
verificationTasks = new Set();
|
|
122
|
+
run(operation) {
|
|
123
|
+
const result = this.pending.then(operation, operation);
|
|
124
|
+
this.pending = result.catch(() => { });
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
constructor({ layout, maxStagingBytes, minFreeBytes = 0, clock = Date, resumeTtl, isSessionActive = () => false, storage = _fs_1.default.promises }) {
|
|
128
|
+
if (!layout || typeof layout !== 'object')
|
|
129
|
+
throw problem('Invalid storage layout');
|
|
130
|
+
(0, validation_js_1.assertSafeUint)(maxStagingBytes, 'maxStagingBytes');
|
|
131
|
+
(0, validation_js_1.assertSafeUint)(minFreeBytes, 'minFreeBytes');
|
|
132
|
+
if (!clock || typeof clock.now !== 'function')
|
|
133
|
+
throw problem('Invalid clock');
|
|
134
|
+
if (resumeTtl !== undefined) {
|
|
135
|
+
(0, validation_js_1.assertSafeUint)(resumeTtl, 'resumeTtl');
|
|
136
|
+
if (resumeTtl === 0)
|
|
137
|
+
throw problem('Invalid resumeTtl');
|
|
138
|
+
}
|
|
139
|
+
if (typeof isSessionActive !== 'function')
|
|
140
|
+
throw problem('Invalid session activity predicate');
|
|
141
|
+
if (!storage || typeof storage !== 'object')
|
|
142
|
+
throw problem('Invalid storage adapter');
|
|
143
|
+
this.layout = layout;
|
|
144
|
+
this.maxStagingBytes = maxStagingBytes;
|
|
145
|
+
this.minFreeBytes = minFreeBytes;
|
|
146
|
+
this.clock = clock;
|
|
147
|
+
this.resumeTtl = resumeTtl;
|
|
148
|
+
this.isSessionActive = isSessionActive;
|
|
149
|
+
this.storage = storage;
|
|
150
|
+
}
|
|
151
|
+
sessionPath(id) {
|
|
152
|
+
return _path_1.default.join(this.layout.sessions, `${id}.json`);
|
|
153
|
+
}
|
|
154
|
+
tarPath(id) {
|
|
155
|
+
return _path_1.default.join(this.layout.staging, `${id}.tar.part`);
|
|
156
|
+
}
|
|
157
|
+
filePath(id) {
|
|
158
|
+
return _path_1.default.join(this.layout.staging, `${id}.part`);
|
|
159
|
+
}
|
|
160
|
+
treeStagingPath(id) {
|
|
161
|
+
return _path_1.default.join(this.layout.staging, `${id}.tree`);
|
|
162
|
+
}
|
|
163
|
+
/** Removes a staged tree through the contained primitives; never `storage.rm`. */
|
|
164
|
+
removeStagedTree(session) {
|
|
165
|
+
return (0, tree_fs_js_1.removeTree)(this.treeStagingPath(session.id), this.layout.staging, this.storage, {
|
|
166
|
+
layout: this.layout
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
journalPath(id) {
|
|
170
|
+
return _path_1.default.join(this.layout.journals, `${id}.json`);
|
|
171
|
+
}
|
|
172
|
+
reserve(session) {
|
|
173
|
+
return session.tarSize + session.size;
|
|
174
|
+
}
|
|
175
|
+
assertReady() {
|
|
176
|
+
if (!this.initialized || this.closing || this.closed) {
|
|
177
|
+
throw problem('Session store is not available');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
metadata(input) {
|
|
181
|
+
return (0, controls_js_1.decodeAnyMetadataRecord)((0, controls_js_1.encodeAnyMetadataRecord)(input));
|
|
182
|
+
}
|
|
183
|
+
fileMetadata(input) {
|
|
184
|
+
const metadata = this.metadata(input);
|
|
185
|
+
if ((0, controls_js_1.isTreeMetadata)(metadata))
|
|
186
|
+
throw problem('Invalid file session metadata');
|
|
187
|
+
return metadata;
|
|
188
|
+
}
|
|
189
|
+
treeFromDisk(id, record) {
|
|
190
|
+
const persisted = record;
|
|
191
|
+
if (persisted.kind !== 'directory' || persisted.transferId !== id) {
|
|
192
|
+
throw problem('Invalid tree session');
|
|
193
|
+
}
|
|
194
|
+
const ownerKey = bytes(persisted.ownerKey, 'owner key');
|
|
195
|
+
uint(persisted.entryCount, 'tree entry count');
|
|
196
|
+
uint(persisted.payloadBytes, 'tree payload size');
|
|
197
|
+
uint(persisted.tarSize, 'TAR size');
|
|
198
|
+
uint(persisted.createdAt, 'creation time');
|
|
199
|
+
uint(persisted.updatedAt, 'update time');
|
|
200
|
+
if (!persisted.partialTar ||
|
|
201
|
+
persisted.partialTar.path !== `${id}.tar.part` ||
|
|
202
|
+
![RECEIVING, VERIFIED, DELETING].includes(persisted.state)) {
|
|
203
|
+
throw problem('Invalid tree session metadata');
|
|
204
|
+
}
|
|
205
|
+
uint(persisted.partialTar.size, 'partial TAR size');
|
|
206
|
+
if (persisted.partialTar.size > persisted.tarSize)
|
|
207
|
+
throw problem('Oversized partial TAR');
|
|
208
|
+
const metadata = (0, controls_js_1.decodeTreeMetadataRecord)((0, controls_js_1.encodeTreeMetadataRecord)({
|
|
209
|
+
v: 1,
|
|
210
|
+
kind: 'directory',
|
|
211
|
+
name: persisted.name,
|
|
212
|
+
...(persisted.sourceParent === undefined ? {} : { sourceParent: persisted.sourceParent }),
|
|
213
|
+
entryCount: persisted.entryCount,
|
|
214
|
+
payloadBytes: persisted.payloadBytes,
|
|
215
|
+
treeSha256: hex(bytes(persisted.treeSha256, 'tree digest')),
|
|
216
|
+
tarSize: persisted.tarSize,
|
|
217
|
+
tarSha256: hex(bytes(persisted.tarSha256, 'TAR digest')),
|
|
218
|
+
transferId: id,
|
|
219
|
+
reset: false
|
|
220
|
+
}));
|
|
221
|
+
(0, tree_manifest_js_1.assertTreeMetadataTransferId)(ownerKey, metadata);
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
transferId: b4a_1.default.from(id, 'hex'),
|
|
225
|
+
ownerKey,
|
|
226
|
+
kind: 'directory',
|
|
227
|
+
name: metadata.name,
|
|
228
|
+
sourceParent: metadata.sourceParent,
|
|
229
|
+
size: metadata.payloadBytes,
|
|
230
|
+
digest: b4a_1.default.from(metadata.treeSha256, 'hex'),
|
|
231
|
+
entryCount: metadata.entryCount,
|
|
232
|
+
tarSize: metadata.tarSize,
|
|
233
|
+
tarDigest: b4a_1.default.from(metadata.tarSha256, 'hex'),
|
|
234
|
+
tarPath: this.tarPath(id),
|
|
235
|
+
treePath: this.treeStagingPath(id),
|
|
236
|
+
partialTarSize: persisted.partialTar.size,
|
|
237
|
+
createdAt: persisted.createdAt,
|
|
238
|
+
updatedAt: persisted.updatedAt,
|
|
239
|
+
state: persisted.state
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
fromDisk(id, record) {
|
|
243
|
+
if (record.version === exports.TREE_SESSION_VERSION)
|
|
244
|
+
return this.treeFromDisk(id, record);
|
|
245
|
+
const persisted = record;
|
|
246
|
+
if ((persisted.version !== LEGACY_VERSION && persisted.version !== VERSION) ||
|
|
247
|
+
persisted.transferId !== id ||
|
|
248
|
+
(persisted.version === LEGACY_VERSION &&
|
|
249
|
+
Object.prototype.hasOwnProperty.call(record, 'sourceParent'))) {
|
|
250
|
+
throw problem('Invalid TAR session');
|
|
251
|
+
}
|
|
252
|
+
const ownerKey = bytes(persisted.ownerKey, 'owner key');
|
|
253
|
+
const transferId = bytes(persisted.transferId, 'transfer ID');
|
|
254
|
+
uint(persisted.fileSize, 'file size');
|
|
255
|
+
uint(persisted.tarSize, 'TAR size');
|
|
256
|
+
uint(persisted.createdAt, 'creation time');
|
|
257
|
+
uint(persisted.updatedAt, 'update time');
|
|
258
|
+
if (!persisted.partialTar ||
|
|
259
|
+
persisted.partialTar.path !== `${id}.tar.part` ||
|
|
260
|
+
![RECEIVING, VERIFIED, DELETING].includes(persisted.state)) {
|
|
261
|
+
throw problem('Invalid TAR session metadata');
|
|
262
|
+
}
|
|
263
|
+
uint(persisted.partialTar.size, 'partial TAR size');
|
|
264
|
+
if (persisted.partialTar.size > persisted.tarSize) {
|
|
265
|
+
throw problem('Oversized partial TAR');
|
|
266
|
+
}
|
|
267
|
+
const metadata = this.fileMetadata({
|
|
268
|
+
v: 1,
|
|
269
|
+
name: persisted.name,
|
|
270
|
+
...(persisted.sourceParent === undefined ? {} : { sourceParent: persisted.sourceParent }),
|
|
271
|
+
fileSize: persisted.fileSize,
|
|
272
|
+
fileSha256: hex(bytes(persisted.fileSha256, 'file digest')),
|
|
273
|
+
tarSize: persisted.tarSize,
|
|
274
|
+
tarSha256: hex(bytes(persisted.tarSha256, 'TAR digest')),
|
|
275
|
+
transferId: id,
|
|
276
|
+
reset: false
|
|
277
|
+
});
|
|
278
|
+
(0, manifest_js_1.assertMetadataTransferId)(ownerKey, metadata);
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
transferId,
|
|
282
|
+
ownerKey,
|
|
283
|
+
kind: 'file',
|
|
284
|
+
name: metadata.name,
|
|
285
|
+
sourceParent: metadata.sourceParent,
|
|
286
|
+
size: metadata.fileSize,
|
|
287
|
+
digest: b4a_1.default.from(metadata.fileSha256, 'hex'),
|
|
288
|
+
tarSize: metadata.tarSize,
|
|
289
|
+
tarDigest: b4a_1.default.from(metadata.tarSha256, 'hex'),
|
|
290
|
+
tarPath: this.tarPath(id),
|
|
291
|
+
partialTarSize: persisted.partialTar.size,
|
|
292
|
+
createdAt: persisted.createdAt,
|
|
293
|
+
updatedAt: persisted.updatedAt,
|
|
294
|
+
state: persisted.state
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Version 3 only adds `sourceParent`, so a session without one is written as
|
|
299
|
+
* version 2. An older server can then still read, and resume, every session
|
|
300
|
+
* whose record it fully understands; only parent-bearing ones are lost to a
|
|
301
|
+
* downgrade.
|
|
302
|
+
*/
|
|
303
|
+
serialize(session) {
|
|
304
|
+
if (session.kind === 'directory') {
|
|
305
|
+
return {
|
|
306
|
+
version: exports.TREE_SESSION_VERSION,
|
|
307
|
+
kind: 'directory',
|
|
308
|
+
transferId: session.id,
|
|
309
|
+
ownerKey: hex(session.ownerKey),
|
|
310
|
+
name: session.name,
|
|
311
|
+
sourceParent: session.sourceParent,
|
|
312
|
+
entryCount: session.entryCount,
|
|
313
|
+
payloadBytes: session.size,
|
|
314
|
+
treeSha256: hex(session.digest),
|
|
315
|
+
tarSize: session.tarSize,
|
|
316
|
+
tarSha256: hex(session.tarDigest),
|
|
317
|
+
partialTar: { path: `${session.id}.tar.part`, size: session.partialTarSize },
|
|
318
|
+
createdAt: session.createdAt,
|
|
319
|
+
updatedAt: session.updatedAt,
|
|
320
|
+
state: session.state
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
version: session.sourceParent === undefined ? LEGACY_VERSION : VERSION,
|
|
325
|
+
transferId: session.id,
|
|
326
|
+
ownerKey: hex(session.ownerKey),
|
|
327
|
+
name: session.name,
|
|
328
|
+
sourceParent: session.sourceParent,
|
|
329
|
+
fileSize: session.size,
|
|
330
|
+
fileSha256: hex(session.digest),
|
|
331
|
+
tarSize: session.tarSize,
|
|
332
|
+
tarSha256: hex(session.tarDigest),
|
|
333
|
+
partialTar: { path: `${session.id}.tar.part`, size: session.partialTarSize },
|
|
334
|
+
createdAt: session.createdAt,
|
|
335
|
+
updatedAt: session.updatedAt,
|
|
336
|
+
state: session.state
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async writeSession(session) {
|
|
340
|
+
await (0, atomic_file_js_1.writeAtomic)(this.sessionPath(session.id), b4a_1.default.from(JSON.stringify(this.serialize(session))), this.storage);
|
|
341
|
+
}
|
|
342
|
+
async remove(filePath, directory) {
|
|
343
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, async () => {
|
|
344
|
+
try {
|
|
345
|
+
await (0, layout_js_1.assertSafeFile)(filePath, this.storage);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
// `assertSafeFile` also throws for a path that exists but is not a
|
|
349
|
+
// regular file, and those errors carry a null cause; `isMissing`
|
|
350
|
+
// absorbs that rather than dereferencing it.
|
|
351
|
+
if ((0, error_code_js_1.isMissing)(error))
|
|
352
|
+
return;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
await this.storage.unlink(filePath);
|
|
356
|
+
});
|
|
357
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, () => syncDirectory(directory, this.storage));
|
|
358
|
+
}
|
|
359
|
+
/** A verified directory session must still own its staging tree. */
|
|
360
|
+
async assertStagedTree(session) {
|
|
361
|
+
const state = await (0, tree_fs_js_1.inspectTreePath)(this.treeStagingPath(session.id), this.layout.staging, this.storage, { layout: this.layout });
|
|
362
|
+
if (state !== 'DIRECTORY')
|
|
363
|
+
throw problem('Session is not verified');
|
|
364
|
+
}
|
|
365
|
+
/** Verified directory sessions mid-commit may only have a durable journal tree. */
|
|
366
|
+
async assertVerifiedDirectorySession(session) {
|
|
367
|
+
const treeState = await (0, tree_fs_js_1.inspectTreePath)(this.treeStagingPath(session.id), this.layout.staging, this.storage, { layout: this.layout });
|
|
368
|
+
if (treeState === 'DIRECTORY') {
|
|
369
|
+
await this.assertStagedTree(session);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const journal = await (0, commit_journal_js_1.readCommitJournal)(session.id, this.layout, this.storage);
|
|
373
|
+
if (!journal || !(0, commit_journal_js_1.isDirectoryJournal)(journal))
|
|
374
|
+
throw problem('Session is not verified');
|
|
375
|
+
}
|
|
376
|
+
async purgeCorruptSession(id) {
|
|
377
|
+
await this.remove(this.tarPath(id), this.layout.staging);
|
|
378
|
+
await this.remove(this.sessionPath(id), this.layout.sessions);
|
|
379
|
+
this.purgedSessions++;
|
|
380
|
+
}
|
|
381
|
+
/** Removes only a regular crash residue and syncs its parent before reporting it purged. */
|
|
382
|
+
async purgeSafeResidue(filePath, directory) {
|
|
383
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, async () => {
|
|
384
|
+
await (0, layout_js_1.assertSafeFile)(filePath, this.storage);
|
|
385
|
+
await this.storage.unlink(filePath);
|
|
386
|
+
});
|
|
387
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, () => syncDirectory(directory, this.storage));
|
|
388
|
+
this.purgedSessions++;
|
|
389
|
+
}
|
|
390
|
+
hashPrefix(session) {
|
|
391
|
+
return (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
392
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'read', this.storage);
|
|
393
|
+
try {
|
|
394
|
+
const stat = await handle.stat();
|
|
395
|
+
if (!stat.isFile() || stat.size !== session.partialTarSize) {
|
|
396
|
+
throw problem('Partial TAR size changed');
|
|
397
|
+
}
|
|
398
|
+
const state = b4a_1.default.alloc(sodium_native_1.default.crypto_hash_sha256_STATEBYTES);
|
|
399
|
+
sodium_native_1.default.crypto_hash_sha256_init(state);
|
|
400
|
+
for (let position = 0; position < session.partialTarSize;) {
|
|
401
|
+
const chunk = await readAll(handle, Math.min(READ_BYTES, session.partialTarSize - position), position);
|
|
402
|
+
sodium_native_1.default.crypto_hash_sha256_update(state, chunk);
|
|
403
|
+
position += chunk.byteLength;
|
|
404
|
+
}
|
|
405
|
+
const digest = b4a_1.default.alloc(32);
|
|
406
|
+
sodium_native_1.default.crypto_hash_sha256_final(state, digest);
|
|
407
|
+
return digest;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
await handle.close();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
ensureTarLength(session) {
|
|
415
|
+
return (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
416
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'write', this.storage);
|
|
417
|
+
try {
|
|
418
|
+
const stat = await handle.stat();
|
|
419
|
+
if (stat.size < session.partialTarSize)
|
|
420
|
+
return false;
|
|
421
|
+
if (stat.size > session.tarSize) {
|
|
422
|
+
throw problem('Oversized partial TAR');
|
|
423
|
+
}
|
|
424
|
+
if (stat.size > session.partialTarSize) {
|
|
425
|
+
await handle.truncate(session.partialTarSize);
|
|
426
|
+
await handle.sync();
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
await handle.close();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async diskReserve(incoming) {
|
|
436
|
+
if (this.minFreeBytes === 0)
|
|
437
|
+
return;
|
|
438
|
+
if (typeof this.storage.statfs !== 'function') {
|
|
439
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.DISK_RESERVE, 'Free-disk reserve is unavailable');
|
|
440
|
+
}
|
|
441
|
+
const stat = await this.storage.statfs(this.layout.staging);
|
|
442
|
+
const available = BigInt(stat.bavail) * BigInt(stat.bsize);
|
|
443
|
+
if (available < BigInt(this.minFreeBytes) + BigInt(this.reservedBytes) + BigInt(incoming)) {
|
|
444
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.DISK_RESERVE, 'Minimum free-disk reserve exceeded');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
init() {
|
|
448
|
+
return this.run(async () => {
|
|
449
|
+
if (this.initialized)
|
|
450
|
+
return;
|
|
451
|
+
for (const directory of [
|
|
452
|
+
this.layout.root,
|
|
453
|
+
this.layout.internal,
|
|
454
|
+
this.layout.staging,
|
|
455
|
+
this.layout.sessions
|
|
456
|
+
]) {
|
|
457
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, () => undefined);
|
|
458
|
+
}
|
|
459
|
+
const names = await this.storage.readdir(this.layout.sessions);
|
|
460
|
+
for (const name of names.sort()) {
|
|
461
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name)) {
|
|
462
|
+
if (/^\.[0-9a-f]{64}\.json\.[0-9a-f]{32,64}\.tmp$/.test(name)) {
|
|
463
|
+
await this.purgeSafeResidue(_path_1.default.join(this.layout.sessions, name), this.layout.sessions);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
throw problem('Invalid session metadata path');
|
|
467
|
+
}
|
|
468
|
+
const id = name.slice(0, -5);
|
|
469
|
+
let record;
|
|
470
|
+
try {
|
|
471
|
+
record = await (0, atomic_file_js_1.readJson)(this.sessionPath(id), this.storage, 16 * 1024);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (!(error instanceof atomic_file_js_1.MetadataFormatError))
|
|
475
|
+
throw error;
|
|
476
|
+
await this.purgeCorruptSession(id);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (record.version === 1) {
|
|
480
|
+
await this.remove(this.sessionPath(id), this.layout.sessions);
|
|
481
|
+
await this.remove(this.tarPath(id), this.layout.staging);
|
|
482
|
+
await this.remove(this.filePath(id), this.layout.staging);
|
|
483
|
+
await this.remove(this.journalPath(id), this.layout.journals);
|
|
484
|
+
this.purgedSessions++;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
let session;
|
|
488
|
+
try {
|
|
489
|
+
session = this.fromDisk(id, record);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (!(error instanceof errors_js_1.SwarmDeployError) || error.code !== errors_js_1.ERRORS.PROTOCOL_INVALID) {
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
await this.purgeCorruptSession(id);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (session.state === DELETING) {
|
|
499
|
+
await this.remove(session.tarPath, this.layout.staging);
|
|
500
|
+
if (session.kind === 'directory')
|
|
501
|
+
await this.removeStagedTree(session);
|
|
502
|
+
else
|
|
503
|
+
await this.remove(this.filePath(id), this.layout.staging);
|
|
504
|
+
await this.remove(this.sessionPath(id), this.layout.sessions);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (session.state === RECEIVING) {
|
|
508
|
+
if (!(await this.ensureTarLength(session))) {
|
|
509
|
+
session.partialTarSize = 0;
|
|
510
|
+
session.updatedAt = this.clock.now();
|
|
511
|
+
await this.writeSession(session);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else if (session.kind === 'directory') {
|
|
515
|
+
const treeState = await (0, tree_fs_js_1.inspectTreePath)(this.treeStagingPath(id), this.layout.staging, this.storage, { layout: this.layout });
|
|
516
|
+
if (treeState !== 'DIRECTORY') {
|
|
517
|
+
const journal = await (0, commit_journal_js_1.readCommitJournal)(id, this.layout, this.storage);
|
|
518
|
+
if (!journal || !(0, commit_journal_js_1.isDirectoryJournal)(journal)) {
|
|
519
|
+
await this.purgeCorruptSession(id);
|
|
520
|
+
if (treeState !== 'MISSING') {
|
|
521
|
+
await (0, tree_fs_js_1.removeTree)(this.treeStagingPath(id), this.layout.staging, this.storage, {
|
|
522
|
+
layout: this.layout
|
|
523
|
+
}).catch(() => { });
|
|
524
|
+
}
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
await this.assertVerifiedDirectorySession(session);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
await (0, layout_js_1.assertSafeFile)(this.filePath(id), this.storage);
|
|
534
|
+
}
|
|
535
|
+
this.sessions.set(id, session);
|
|
536
|
+
this.reservedBytes += this.reserve(session);
|
|
537
|
+
if (this.reservedBytes > this.maxStagingBytes) {
|
|
538
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.STAGING_LIMIT, 'Recovered TAR sessions exceed staging limit');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const expected = new Set();
|
|
542
|
+
for (const session of this.sessions.values()) {
|
|
543
|
+
expected.add(`${session.id}.tar.part`);
|
|
544
|
+
if (session.state === VERIFIED) {
|
|
545
|
+
expected.add(session.kind === 'directory' ? `${session.id}.tree` : `${session.id}.part`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const name of await this.storage.readdir(this.layout.staging)) {
|
|
549
|
+
if (expected.has(name))
|
|
550
|
+
continue;
|
|
551
|
+
const tree = /^([0-9a-f]{64})\.tree$/.exec(name);
|
|
552
|
+
if (tree) {
|
|
553
|
+
// A journal-owned tree belongs to a commit in flight; anything else is residue.
|
|
554
|
+
if (await (0, commit_journal_js_1.readCommitJournal)(tree[1], this.layout, this.storage))
|
|
555
|
+
continue;
|
|
556
|
+
await (0, tree_fs_js_1.removeTree)(_path_1.default.join(this.layout.staging, name), this.layout.staging, this.storage, {
|
|
557
|
+
layout: this.layout
|
|
558
|
+
});
|
|
559
|
+
this.purgedSessions++;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const staged = /^([0-9a-f]{64})\.(?:part|tar\.part)$/.exec(name);
|
|
563
|
+
if (staged) {
|
|
564
|
+
const journal = await (0, commit_journal_js_1.readCommitJournal)(staged[1], this.layout, this.storage);
|
|
565
|
+
if (journal)
|
|
566
|
+
continue;
|
|
567
|
+
await this.purgeSafeResidue(_path_1.default.join(this.layout.staging, name), this.layout.staging);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
await this.purgeSafeResidue(_path_1.default.join(this.layout.staging, name), this.layout.staging);
|
|
571
|
+
}
|
|
572
|
+
this.initialized = true;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
admit(ownerKey, input) {
|
|
576
|
+
return this.run(async () => {
|
|
577
|
+
this.assertReady();
|
|
578
|
+
key(ownerKey, 'owner key');
|
|
579
|
+
const metadata = this.metadata(input);
|
|
580
|
+
if ((0, controls_js_1.isTreeMetadata)(metadata))
|
|
581
|
+
(0, tree_manifest_js_1.assertTreeMetadataTransferId)(ownerKey, metadata);
|
|
582
|
+
else
|
|
583
|
+
(0, manifest_js_1.assertMetadataTransferId)(ownerKey, metadata);
|
|
584
|
+
const id = metadata.transferId;
|
|
585
|
+
if (this.resumeTtl !== undefined)
|
|
586
|
+
await this.expireUnlocked(this.resumeTtl);
|
|
587
|
+
let session = this.sessions.get(id);
|
|
588
|
+
if (session) {
|
|
589
|
+
if (!sameMetadata(session, metadata, ownerKey)) {
|
|
590
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'Transfer ID belongs to different immutable metadata');
|
|
591
|
+
}
|
|
592
|
+
if (this.verifying.has(id)) {
|
|
593
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is being verified');
|
|
594
|
+
}
|
|
595
|
+
if (session.state === DELETING) {
|
|
596
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is quarantined');
|
|
597
|
+
}
|
|
598
|
+
if (session.state === VERIFIED) {
|
|
599
|
+
if (metadata.reset)
|
|
600
|
+
throw problem('Cannot reset verified TAR session');
|
|
601
|
+
return { status: 'VERIFIED' };
|
|
602
|
+
}
|
|
603
|
+
if (metadata.reset && session.state === RECEIVING) {
|
|
604
|
+
await this.resetUnlocked(session);
|
|
605
|
+
return { status: 'ACCEPT', offset: 0 };
|
|
606
|
+
}
|
|
607
|
+
if (session.state !== RECEIVING || session.partialTarSize === 0) {
|
|
608
|
+
return { status: 'ACCEPT', offset: 0 };
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
status: 'RESUME',
|
|
612
|
+
offset: session.partialTarSize,
|
|
613
|
+
prefixSha256: await this.hashPrefix(session)
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
for (const candidate of this.sessions.values()) {
|
|
617
|
+
if (candidate.name === metadata.name) {
|
|
618
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'Destination has an active session');
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const reservation = metadata.tarSize + ((0, controls_js_1.isTreeMetadata)(metadata) ? metadata.payloadBytes : metadata.fileSize);
|
|
622
|
+
if (this.reservedBytes > this.maxStagingBytes - reservation) {
|
|
623
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.STAGING_LIMIT, 'Staging capacity exceeded');
|
|
624
|
+
}
|
|
625
|
+
await this.diskReserve(reservation);
|
|
626
|
+
const now = this.clock.now();
|
|
627
|
+
session = (0, controls_js_1.isTreeMetadata)(metadata)
|
|
628
|
+
? {
|
|
629
|
+
id,
|
|
630
|
+
transferId: b4a_1.default.from(id, 'hex'),
|
|
631
|
+
ownerKey: b4a_1.default.from(ownerKey),
|
|
632
|
+
kind: 'directory',
|
|
633
|
+
name: metadata.name,
|
|
634
|
+
sourceParent: metadata.sourceParent,
|
|
635
|
+
size: metadata.payloadBytes,
|
|
636
|
+
digest: b4a_1.default.from(metadata.treeSha256, 'hex'),
|
|
637
|
+
entryCount: metadata.entryCount,
|
|
638
|
+
tarSize: metadata.tarSize,
|
|
639
|
+
tarDigest: b4a_1.default.from(metadata.tarSha256, 'hex'),
|
|
640
|
+
tarPath: this.tarPath(id),
|
|
641
|
+
treePath: this.treeStagingPath(id),
|
|
642
|
+
partialTarSize: 0,
|
|
643
|
+
createdAt: now,
|
|
644
|
+
updatedAt: now,
|
|
645
|
+
state: RECEIVING
|
|
646
|
+
}
|
|
647
|
+
: {
|
|
648
|
+
id,
|
|
649
|
+
transferId: b4a_1.default.from(id, 'hex'),
|
|
650
|
+
ownerKey: b4a_1.default.from(ownerKey),
|
|
651
|
+
kind: 'file',
|
|
652
|
+
name: metadata.name,
|
|
653
|
+
sourceParent: metadata.sourceParent,
|
|
654
|
+
size: metadata.fileSize,
|
|
655
|
+
digest: b4a_1.default.from(metadata.fileSha256, 'hex'),
|
|
656
|
+
tarSize: metadata.tarSize,
|
|
657
|
+
tarDigest: b4a_1.default.from(metadata.tarSha256, 'hex'),
|
|
658
|
+
tarPath: this.tarPath(id),
|
|
659
|
+
partialTarSize: 0,
|
|
660
|
+
createdAt: now,
|
|
661
|
+
updatedAt: now,
|
|
662
|
+
state: RECEIVING
|
|
663
|
+
};
|
|
664
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
665
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'create', this.storage);
|
|
666
|
+
await handle.sync();
|
|
667
|
+
await handle.close();
|
|
668
|
+
await syncDirectory(this.layout.staging, this.storage);
|
|
669
|
+
});
|
|
670
|
+
await this.writeSession(session);
|
|
671
|
+
this.sessions.set(id, session);
|
|
672
|
+
this.reservedBytes += reservation;
|
|
673
|
+
return { status: 'ACCEPT', offset: 0 };
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
append(ownerKey, input, offset, data) {
|
|
677
|
+
return this.run(async () => {
|
|
678
|
+
this.assertReady();
|
|
679
|
+
key(ownerKey, 'owner key');
|
|
680
|
+
const metadata = this.metadata(input);
|
|
681
|
+
const session = this.sessions.get(metadata.transferId);
|
|
682
|
+
if (!session || !sameMetadata(session, metadata, ownerKey)) {
|
|
683
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'Unknown or mismatched TAR session');
|
|
684
|
+
}
|
|
685
|
+
if (this.verifying.has(session.id)) {
|
|
686
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is being verified');
|
|
687
|
+
}
|
|
688
|
+
if (session.state === DELETING) {
|
|
689
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is quarantined');
|
|
690
|
+
}
|
|
691
|
+
if (session.state !== RECEIVING)
|
|
692
|
+
throw problem('Cannot append to verified TAR session');
|
|
693
|
+
if (!Number.isSafeInteger(offset) ||
|
|
694
|
+
offset !== session.partialTarSize ||
|
|
695
|
+
!b4a_1.default.isBuffer(data)) {
|
|
696
|
+
throw problem('TAR append offset does not match durable progress');
|
|
697
|
+
}
|
|
698
|
+
if (data.byteLength > session.tarSize - offset) {
|
|
699
|
+
throw problem('TAR append exceeds deterministic length');
|
|
700
|
+
}
|
|
701
|
+
if (data.byteLength === 0)
|
|
702
|
+
throw problem('TAR append must make positive progress');
|
|
703
|
+
const previous = session.partialTarSize;
|
|
704
|
+
try {
|
|
705
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
706
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'write', this.storage);
|
|
707
|
+
try {
|
|
708
|
+
await writeAll(handle, data, offset);
|
|
709
|
+
await handle.sync();
|
|
710
|
+
}
|
|
711
|
+
finally {
|
|
712
|
+
await handle.close();
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
catch (cause) {
|
|
717
|
+
await this.rollbackOrQuarantine(session, previous, cause);
|
|
718
|
+
throw cause;
|
|
719
|
+
}
|
|
720
|
+
session.partialTarSize += data.byteLength;
|
|
721
|
+
session.updatedAt = this.clock.now();
|
|
722
|
+
try {
|
|
723
|
+
await this.writeSession(session);
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
if (!(0, atomic_file_js_1.atomicWriteRenamed)(error)) {
|
|
727
|
+
await this.rollbackOrQuarantine(session, previous, error);
|
|
728
|
+
session.partialTarSize = previous;
|
|
729
|
+
}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
return session.partialTarSize;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async rollbackTar(session, length) {
|
|
736
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
737
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'write', this.storage);
|
|
738
|
+
try {
|
|
739
|
+
await handle.truncate(length);
|
|
740
|
+
await handle.sync();
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
await handle.close();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async rollbackOrQuarantine(session, length, cause) {
|
|
748
|
+
try {
|
|
749
|
+
await this.rollbackTar(session, length);
|
|
750
|
+
}
|
|
751
|
+
catch (cleanupCause) {
|
|
752
|
+
session.state = DELETING;
|
|
753
|
+
try {
|
|
754
|
+
await this.writeSession(session);
|
|
755
|
+
}
|
|
756
|
+
catch (quarantineCause) {
|
|
757
|
+
throw Object.assign(problem('TAR append rollback and quarantine failed', cause), {
|
|
758
|
+
cleanupCause,
|
|
759
|
+
quarantineCause
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
throw Object.assign(problem('TAR append and rollback failed', cause), { cleanupCause });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async resetUnlocked(session) {
|
|
766
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
767
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'write', this.storage);
|
|
768
|
+
try {
|
|
769
|
+
await handle.truncate(0);
|
|
770
|
+
await handle.sync();
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
await handle.close();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
session.partialTarSize = 0;
|
|
777
|
+
session.updatedAt = this.clock.now();
|
|
778
|
+
await this.writeSession(session);
|
|
779
|
+
}
|
|
780
|
+
async quarantineVerificationFailure(session, cause, cleanupCause) {
|
|
781
|
+
const failure = Object.assign(problem('TAR verification cleanup failed', cause), {
|
|
782
|
+
cleanupCause
|
|
783
|
+
});
|
|
784
|
+
try {
|
|
785
|
+
await this.run(async () => {
|
|
786
|
+
session.state = DELETING;
|
|
787
|
+
session.updatedAt = this.clock.now();
|
|
788
|
+
await this.writeSession(session);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch (quarantineCause) {
|
|
792
|
+
throw Object.assign(failure, { quarantineCause });
|
|
793
|
+
}
|
|
794
|
+
throw failure;
|
|
795
|
+
}
|
|
796
|
+
verify(ownerKey, input) {
|
|
797
|
+
const task = (async () => {
|
|
798
|
+
const prepared = await this.run(() => {
|
|
799
|
+
this.assertReady();
|
|
800
|
+
key(ownerKey, 'owner key');
|
|
801
|
+
const metadata = this.metadata(input);
|
|
802
|
+
const session = this.sessions.get(metadata.transferId);
|
|
803
|
+
if (!session ||
|
|
804
|
+
session.state !== RECEIVING ||
|
|
805
|
+
!sameMetadata(session, metadata, ownerKey) ||
|
|
806
|
+
this.verifying.has(session.id)) {
|
|
807
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'Unknown or mismatched TAR session');
|
|
808
|
+
}
|
|
809
|
+
if (session.partialTarSize !== session.tarSize)
|
|
810
|
+
throw problem('TAR transfer is incomplete');
|
|
811
|
+
this.verifying.add(session.id);
|
|
812
|
+
return { metadata, session };
|
|
813
|
+
});
|
|
814
|
+
const { metadata, session } = prepared;
|
|
815
|
+
let file = null;
|
|
816
|
+
let fileOffset = 0;
|
|
817
|
+
try {
|
|
818
|
+
if (session.kind === 'directory') {
|
|
819
|
+
await this.extractTree(session, metadata);
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
file = await (0, layout_js_1.openSafeRegularFile)(this.filePath(session.id), 'create', this.storage);
|
|
823
|
+
const source = this.readTar(session);
|
|
824
|
+
await (0, extract_js_1.validateAndExtractTar)(source, metadata, {
|
|
825
|
+
writeTar: () => { },
|
|
826
|
+
writeFile: async (chunk) => {
|
|
827
|
+
await writeAll(file, chunk, fileOffset);
|
|
828
|
+
fileOffset += chunk.byteLength;
|
|
829
|
+
},
|
|
830
|
+
complete: async () => {
|
|
831
|
+
await file.sync();
|
|
832
|
+
},
|
|
833
|
+
abort: async () => {
|
|
834
|
+
await file?.close().catch(() => { });
|
|
835
|
+
file = null;
|
|
836
|
+
await this.remove(this.filePath(session.id), this.layout.staging).catch(() => { });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
await file.close();
|
|
840
|
+
file = null;
|
|
841
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => syncDirectory(this.layout.staging, this.storage));
|
|
842
|
+
}
|
|
843
|
+
await this.run(async () => {
|
|
844
|
+
if (this.sessions.get(session.id) !== session ||
|
|
845
|
+
session.state !== RECEIVING ||
|
|
846
|
+
!this.verifying.has(session.id)) {
|
|
847
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session changed during verification');
|
|
848
|
+
}
|
|
849
|
+
const previousUpdatedAt = session.updatedAt;
|
|
850
|
+
session.state = VERIFIED;
|
|
851
|
+
session.updatedAt = this.clock.now();
|
|
852
|
+
try {
|
|
853
|
+
await this.writeSession(session);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
if (!(0, atomic_file_js_1.atomicWriteRenamed)(error)) {
|
|
857
|
+
session.state = RECEIVING;
|
|
858
|
+
session.updatedAt = previousUpdatedAt;
|
|
859
|
+
}
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
return session;
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
if (file)
|
|
867
|
+
await file.close().catch(() => { });
|
|
868
|
+
if (session.state !== VERIFIED) {
|
|
869
|
+
try {
|
|
870
|
+
if (session.kind === 'directory')
|
|
871
|
+
await this.removeStagedTree(session);
|
|
872
|
+
else
|
|
873
|
+
await this.remove(this.filePath(session.id), this.layout.staging);
|
|
874
|
+
}
|
|
875
|
+
catch (cleanupCause) {
|
|
876
|
+
await this.quarantineVerificationFailure(session, error, cleanupCause);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
finally {
|
|
882
|
+
await this.run(() => {
|
|
883
|
+
this.verifying.delete(session.id);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
})();
|
|
887
|
+
this.verificationTasks.add(task);
|
|
888
|
+
const settled = () => {
|
|
889
|
+
this.verificationTasks.delete(task);
|
|
890
|
+
};
|
|
891
|
+
task.then(settled, settled);
|
|
892
|
+
return task;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Extracts the complete directory archive into its staging tree, then proves
|
|
896
|
+
* the tree from the bytes on disk alone: the recomputed canonical digest,
|
|
897
|
+
* entry count, and payload size must equal the offered values.
|
|
898
|
+
*/
|
|
899
|
+
async extractTree(session, metadata) {
|
|
900
|
+
const treePath = this.treeStagingPath(session.id);
|
|
901
|
+
const options = { layout: this.layout };
|
|
902
|
+
// Residue of an interrupted attempt never survives into a new extraction.
|
|
903
|
+
await (0, tree_fs_js_1.removeTree)(treePath, this.layout.staging, this.storage, options);
|
|
904
|
+
const target = (0, tree_staging_js_1.createTreeStagingTarget)(treePath, this.layout.staging, this.storage, this.layout);
|
|
905
|
+
await (0, tree_extract_js_1.validateAndExtractTreeTar)(this.readTar(session), metadata, target);
|
|
906
|
+
const staged = await (0, tree_fs_js_1.digestTree)(treePath, this.storage);
|
|
907
|
+
if (staged.entryCount !== metadata.entryCount ||
|
|
908
|
+
staged.payloadBytes !== metadata.payloadBytes ||
|
|
909
|
+
!sodium_native_1.default.sodium_memcmp(staged.treeSha256, b4a_1.default.from(metadata.treeSha256, 'hex'))) {
|
|
910
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Staged tree digest mismatch');
|
|
911
|
+
}
|
|
912
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => syncDirectory(this.layout.staging, this.storage));
|
|
913
|
+
}
|
|
914
|
+
async *readTar(session) {
|
|
915
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(session.tarPath, 'read', this.storage);
|
|
916
|
+
try {
|
|
917
|
+
for (let position = 0; position < session.tarSize;) {
|
|
918
|
+
const chunk = await readAll(handle, Math.min(READ_BYTES, session.tarSize - position), position);
|
|
919
|
+
position += chunk.byteLength;
|
|
920
|
+
yield chunk;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
finally {
|
|
924
|
+
await handle.close();
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
readVerified(transferId) {
|
|
928
|
+
return this.run(async () => {
|
|
929
|
+
this.assertReady();
|
|
930
|
+
key(transferId, 'transfer ID');
|
|
931
|
+
const session = this.sessions.get(hex(transferId));
|
|
932
|
+
if (!session || session.state !== VERIFIED)
|
|
933
|
+
throw problem('Session is not verified');
|
|
934
|
+
if (session.kind === 'directory')
|
|
935
|
+
await this.assertVerifiedDirectorySession(session);
|
|
936
|
+
else
|
|
937
|
+
await (0, layout_js_1.assertSafeFile)(this.filePath(session.id), this.storage);
|
|
938
|
+
return session;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Drops a session from the in-memory authority exactly once.
|
|
943
|
+
*
|
|
944
|
+
* `reservedBytes` must always equal the sum of `reserve()` over `sessions`,
|
|
945
|
+
* so the bookkeeping runs in a `finally`: a failed unlink would otherwise
|
|
946
|
+
* strand the reservation for the lifetime of the process, and leave the name
|
|
947
|
+
* permanently colliding in `admit()`. Any file that survives the failure is a
|
|
948
|
+
* crash residue, which `init()` purges on the next start.
|
|
949
|
+
*/
|
|
950
|
+
async removeSession(session) {
|
|
951
|
+
session.state = DELETING;
|
|
952
|
+
session.updatedAt = this.clock.now();
|
|
953
|
+
try {
|
|
954
|
+
await this.writeSession(session);
|
|
955
|
+
await this.remove(session.tarPath, this.layout.staging);
|
|
956
|
+
if (session.kind === 'directory')
|
|
957
|
+
await this.removeStagedTree(session);
|
|
958
|
+
else
|
|
959
|
+
await this.remove(this.filePath(session.id), this.layout.staging);
|
|
960
|
+
await this.remove(this.sessionPath(session.id), this.layout.sessions);
|
|
961
|
+
}
|
|
962
|
+
finally {
|
|
963
|
+
if (this.sessions.delete(session.id))
|
|
964
|
+
this.reservedBytes -= this.reserve(session);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
delete(transferId) {
|
|
968
|
+
return this.run(async () => {
|
|
969
|
+
this.assertReady();
|
|
970
|
+
key(transferId, 'transfer ID');
|
|
971
|
+
const id = hex(transferId);
|
|
972
|
+
const session = this.sessions.get(id);
|
|
973
|
+
if (!session)
|
|
974
|
+
return false;
|
|
975
|
+
if (this.verifying.has(id)) {
|
|
976
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is being verified');
|
|
977
|
+
}
|
|
978
|
+
if (session.state === DELETING) {
|
|
979
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, 'TAR session is quarantined');
|
|
980
|
+
}
|
|
981
|
+
await this.removeSession(session);
|
|
982
|
+
return true;
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Sweeps expired sessions without letting one failure abort the rest.
|
|
987
|
+
*
|
|
988
|
+
* `admit()` runs this opportunistically on every offer, so a single session
|
|
989
|
+
* whose files cannot be unlinked must not deny service to every later
|
|
990
|
+
* upload. Failures are counted in `strandedSessions` and reported to the
|
|
991
|
+
* explicit `expire()` caller, which asked for the sweep and can act on it.
|
|
992
|
+
*/
|
|
993
|
+
async expireUnlocked(ttl, predicate = () => true) {
|
|
994
|
+
let removed = 0;
|
|
995
|
+
let failure = null;
|
|
996
|
+
for (const session of [...this.sessions.values()]) {
|
|
997
|
+
if (this.verifying.has(session.id) ||
|
|
998
|
+
session.state === DELETING ||
|
|
999
|
+
this.clock.now() - session.updatedAt <= ttl ||
|
|
1000
|
+
!predicate(session) ||
|
|
1001
|
+
this.isSessionActive(session)) {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await this.removeSession(session);
|
|
1006
|
+
removed++;
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
this.strandedSessions++;
|
|
1010
|
+
if (failure === null)
|
|
1011
|
+
failure = error;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { removed, failure };
|
|
1015
|
+
}
|
|
1016
|
+
expire(ttl, predicate = () => true) {
|
|
1017
|
+
return this.run(async () => {
|
|
1018
|
+
this.assertReady();
|
|
1019
|
+
const { removed, failure } = await this.expireUnlocked(ttl, predicate);
|
|
1020
|
+
if (failure !== null)
|
|
1021
|
+
throw failure;
|
|
1022
|
+
return removed;
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
retireCommitted(transferId) {
|
|
1026
|
+
return this.delete(transferId);
|
|
1027
|
+
}
|
|
1028
|
+
close() {
|
|
1029
|
+
if (this.closePromise)
|
|
1030
|
+
return this.closePromise;
|
|
1031
|
+
this.closePromise = (async () => {
|
|
1032
|
+
await this.run(() => {
|
|
1033
|
+
this.closing = true;
|
|
1034
|
+
});
|
|
1035
|
+
await Promise.allSettled([...this.verificationTasks]);
|
|
1036
|
+
await this.run(() => {
|
|
1037
|
+
this.closed = true;
|
|
1038
|
+
});
|
|
1039
|
+
})();
|
|
1040
|
+
return this.closePromise;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
exports.TarSessionStore = TarSessionStore;
|
|
1044
|
+
//# sourceMappingURL=tar-session-store.js.map
|