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,1691 @@
|
|
|
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.MAX_COMMIT_METADATA_BYTES = exports.COMMIT_VERSION = exports.CorruptJournalError = exports.CommitStore = 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 files_js_1 = require("../files.js");
|
|
14
|
+
const hash_js_1 = require("../tar-protocol/hash.js");
|
|
15
|
+
const manifest_js_1 = require("../tar-protocol/manifest.js");
|
|
16
|
+
const tree_manifest_js_1 = require("../tar-protocol/tree-manifest.js");
|
|
17
|
+
const validation_js_1 = require("../validation.js");
|
|
18
|
+
const layout_js_1 = require("./layout.js");
|
|
19
|
+
const atomic_file_js_1 = require("./atomic-file.js");
|
|
20
|
+
const commit_journal_js_1 = require("./commit-journal.js");
|
|
21
|
+
Object.defineProperty(exports, "CorruptJournalError", { enumerable: true, get: function () { return commit_journal_js_1.CorruptJournalError; } });
|
|
22
|
+
Object.defineProperty(exports, "COMMIT_VERSION", { enumerable: true, get: function () { return commit_journal_js_1.COMMIT_VERSION; } });
|
|
23
|
+
Object.defineProperty(exports, "MAX_COMMIT_METADATA_BYTES", { enumerable: true, get: function () { return commit_journal_js_1.MAX_COMMIT_METADATA_BYTES; } });
|
|
24
|
+
const root_coordinator_js_1 = require("./root-coordinator.js");
|
|
25
|
+
const tree_fs_js_1 = require("./tree-fs.js");
|
|
26
|
+
function storageError(message, cause = null) {
|
|
27
|
+
return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.PROTOCOL_INVALID, message, cause);
|
|
28
|
+
}
|
|
29
|
+
function commitError(message, cause = null) {
|
|
30
|
+
return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.COMMIT_FAILED, message, cause);
|
|
31
|
+
}
|
|
32
|
+
function existsError(message) {
|
|
33
|
+
return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_EXISTS, message);
|
|
34
|
+
}
|
|
35
|
+
function assertNotAborted(signal) {
|
|
36
|
+
if (signal?.aborted)
|
|
37
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.ABORTED, 'Upload was aborted');
|
|
38
|
+
}
|
|
39
|
+
function toHex(bytes) {
|
|
40
|
+
return b4a_1.default.toString(bytes, 'hex');
|
|
41
|
+
}
|
|
42
|
+
function sameHex32(left, right) {
|
|
43
|
+
return (/^[0-9a-f]{64}$/.test(left) &&
|
|
44
|
+
/^[0-9a-f]{64}$/.test(right) &&
|
|
45
|
+
sodium_native_1.default.sodium_memcmp(b4a_1.default.from(left, 'hex'), b4a_1.default.from(right, 'hex')));
|
|
46
|
+
}
|
|
47
|
+
function isCommitRecordName(name) {
|
|
48
|
+
return typeof name === 'string' && /^[0-9a-f]{64}\.json$/.test(name);
|
|
49
|
+
}
|
|
50
|
+
function attemptId() {
|
|
51
|
+
const bytes = b4a_1.default.allocUnsafe(32);
|
|
52
|
+
sodium_native_1.default.randombytes_buf(bytes);
|
|
53
|
+
return toHex(bytes);
|
|
54
|
+
}
|
|
55
|
+
function fingerprint(ownerKey) {
|
|
56
|
+
return toHex((0, hash_js_1.sodiumSha256)(ownerKey));
|
|
57
|
+
}
|
|
58
|
+
function replacementsEqual(left, right) {
|
|
59
|
+
if (left === undefined || right === undefined)
|
|
60
|
+
return left === right;
|
|
61
|
+
return (left.name === right.name &&
|
|
62
|
+
sameHex32(left.transferId, right.transferId) &&
|
|
63
|
+
left.historyName === right.historyName);
|
|
64
|
+
}
|
|
65
|
+
function releasesEqual(left, right) {
|
|
66
|
+
if (left === undefined || right === undefined)
|
|
67
|
+
return left === right;
|
|
68
|
+
return left.series === right.series && left.version === right.version;
|
|
69
|
+
}
|
|
70
|
+
function recordsEqual(left, right) {
|
|
71
|
+
return (left.version === right.version &&
|
|
72
|
+
left.kind === right.kind &&
|
|
73
|
+
left.name === right.name &&
|
|
74
|
+
left.size === right.size &&
|
|
75
|
+
left.entryCount === right.entryCount &&
|
|
76
|
+
sameHex32(left.sha256, right.sha256) &&
|
|
77
|
+
left.committedAt === right.committedAt &&
|
|
78
|
+
left.uploaderFingerprint === right.uploaderFingerprint &&
|
|
79
|
+
sameHex32(left.transferId, right.transferId) &&
|
|
80
|
+
releasesEqual(left.release, right.release) &&
|
|
81
|
+
replacementsEqual(left.replaces, right.replaces));
|
|
82
|
+
}
|
|
83
|
+
function sameContent(left, right) {
|
|
84
|
+
return left.size === right.size && sameHex32(left.sha256, right.sha256);
|
|
85
|
+
}
|
|
86
|
+
function snapshotRelease(release) {
|
|
87
|
+
if (release === null || release === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const snapshot = {
|
|
90
|
+
series: release.series,
|
|
91
|
+
...(release.version === undefined ? {} : { version: release.version })
|
|
92
|
+
};
|
|
93
|
+
(0, commit_journal_js_1.assertCommitRelease)(snapshot);
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
96
|
+
/** The record the old sidecar becomes once its inode is only reachable as history. */
|
|
97
|
+
function historyRecord(oldRecord, name) {
|
|
98
|
+
return (0, commit_journal_js_1.assertCommitRecord)({ ...oldRecord, name });
|
|
99
|
+
}
|
|
100
|
+
function assertSession(session) {
|
|
101
|
+
if (!session || typeof session !== 'object')
|
|
102
|
+
throw storageError('Invalid commit session');
|
|
103
|
+
const candidate = session;
|
|
104
|
+
if (candidate.state !== 'verified')
|
|
105
|
+
throw storageError('Session is not verified');
|
|
106
|
+
if (!(0, commit_journal_js_1.isHex)(candidate.id))
|
|
107
|
+
throw storageError('Invalid session ID');
|
|
108
|
+
(0, validation_js_1.assertFixed32)(candidate.transferId, 'session transfer ID');
|
|
109
|
+
(0, validation_js_1.assertFixed32)(candidate.ownerKey, 'session owner key');
|
|
110
|
+
if (typeof candidate.name !== 'string')
|
|
111
|
+
throw storageError('Invalid filename');
|
|
112
|
+
(0, files_js_1.validateBasename)(candidate.name);
|
|
113
|
+
(0, validation_js_1.assertSafeUint)(candidate.size, 'session size');
|
|
114
|
+
(0, validation_js_1.assertFixed32)(candidate.digest, 'session digest');
|
|
115
|
+
if (!sameHex32(candidate.id, toHex(candidate.transferId))) {
|
|
116
|
+
throw storageError('Session ID mismatch');
|
|
117
|
+
}
|
|
118
|
+
(0, validation_js_1.assertSafeUint)(candidate.tarSize, 'session TAR size');
|
|
119
|
+
(0, validation_js_1.assertFixed32)(candidate.tarDigest, 'session TAR digest');
|
|
120
|
+
if (candidate.kind === 'directory') {
|
|
121
|
+
(0, validation_js_1.assertSafeUint)(candidate.entryCount, 'session entry count');
|
|
122
|
+
(0, tree_manifest_js_1.assertTreeMetadataTransferId)(candidate.ownerKey, {
|
|
123
|
+
v: 1,
|
|
124
|
+
kind: 'directory',
|
|
125
|
+
name: candidate.name,
|
|
126
|
+
...(candidate.sourceParent === undefined
|
|
127
|
+
? {}
|
|
128
|
+
: { sourceParent: candidate.sourceParent }),
|
|
129
|
+
entryCount: candidate.entryCount,
|
|
130
|
+
payloadBytes: candidate.size,
|
|
131
|
+
treeSha256: toHex(candidate.digest),
|
|
132
|
+
tarSize: candidate.tarSize,
|
|
133
|
+
tarSha256: toHex(candidate.tarDigest),
|
|
134
|
+
transferId: candidate.id,
|
|
135
|
+
reset: false
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (candidate.kind !== 'file' && candidate.kind !== undefined) {
|
|
140
|
+
throw storageError('Invalid session artifact kind');
|
|
141
|
+
}
|
|
142
|
+
(0, manifest_js_1.assertMetadataTransferId)(candidate.ownerKey, {
|
|
143
|
+
v: 1,
|
|
144
|
+
name: candidate.name,
|
|
145
|
+
...(candidate.sourceParent === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { sourceParent: candidate.sourceParent }),
|
|
148
|
+
fileSize: candidate.size,
|
|
149
|
+
fileSha256: toHex(candidate.digest),
|
|
150
|
+
tarSize: candidate.tarSize,
|
|
151
|
+
tarSha256: toHex(candidate.tarDigest),
|
|
152
|
+
transferId: candidate.id,
|
|
153
|
+
reset: false
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function readExactly(handle, bytes, position) {
|
|
157
|
+
let offset = 0;
|
|
158
|
+
while (offset < bytes.byteLength) {
|
|
159
|
+
const read = await handle.read(bytes, offset, bytes.byteLength - offset, position + offset);
|
|
160
|
+
const count = typeof read === 'number' ? read : read.bytesRead;
|
|
161
|
+
if (!Number.isSafeInteger(count) || count <= 0)
|
|
162
|
+
return false;
|
|
163
|
+
offset += count;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
async function writeAll(handle, bytes) {
|
|
168
|
+
let offset = 0;
|
|
169
|
+
while (offset < bytes.byteLength) {
|
|
170
|
+
const written = await handle.write(bytes, offset, bytes.byteLength - offset, offset);
|
|
171
|
+
const count = typeof written === 'number' ? written : written.bytesWritten;
|
|
172
|
+
if (!Number.isSafeInteger(count) || count <= 0) {
|
|
173
|
+
throw storageError('Unable to write commit journal');
|
|
174
|
+
}
|
|
175
|
+
offset += count;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function syncDirectory(directory, storage) {
|
|
179
|
+
const handle = await storage.open(directory, 'r');
|
|
180
|
+
try {
|
|
181
|
+
await handle.sync();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function digestExactFile(filePath, size, storage, syncFirst = false) {
|
|
188
|
+
return (0, layout_js_1.withSafeDirectoryIdentity)(_path_1.default.dirname(filePath), storage, async () => {
|
|
189
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(filePath, 'read', storage);
|
|
190
|
+
try {
|
|
191
|
+
if (syncFirst)
|
|
192
|
+
await handle.sync();
|
|
193
|
+
const before = await handle.stat();
|
|
194
|
+
if (!before.isFile() || before.size !== size)
|
|
195
|
+
return null;
|
|
196
|
+
const hash = new hash_js_1.SodiumSha256();
|
|
197
|
+
let position = 0;
|
|
198
|
+
while (position < size) {
|
|
199
|
+
const bytes = b4a_1.default.alloc(Math.min(64 * 1024, size - position));
|
|
200
|
+
if (!(await readExactly(handle, bytes, position)))
|
|
201
|
+
return null;
|
|
202
|
+
hash.update(bytes);
|
|
203
|
+
position += bytes.byteLength;
|
|
204
|
+
}
|
|
205
|
+
const after = await handle.stat();
|
|
206
|
+
if (!after.isFile() ||
|
|
207
|
+
after.size !== size ||
|
|
208
|
+
after.dev !== before.dev ||
|
|
209
|
+
after.ino !== before.ino) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
digest: hash.digest(),
|
|
214
|
+
identity: (0, commit_journal_js_1.fileIdentity)(before)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await handle.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
class CommitStore {
|
|
223
|
+
layout;
|
|
224
|
+
clock;
|
|
225
|
+
storage;
|
|
226
|
+
logger;
|
|
227
|
+
journalAttempts;
|
|
228
|
+
constructor({ layout, clock = Date, storage = _fs_1.default.promises, logger = null }) {
|
|
229
|
+
if (!layout || typeof layout !== 'object')
|
|
230
|
+
throw storageError('Invalid storage layout');
|
|
231
|
+
if (!clock || typeof clock.now !== 'function')
|
|
232
|
+
throw storageError('Invalid clock');
|
|
233
|
+
if (!storage || typeof storage !== 'object')
|
|
234
|
+
throw storageError('Invalid storage adapter');
|
|
235
|
+
this.layout = layout;
|
|
236
|
+
this.clock = clock;
|
|
237
|
+
this.storage = storage;
|
|
238
|
+
this.logger = logger;
|
|
239
|
+
this.journalAttempts = new Map();
|
|
240
|
+
}
|
|
241
|
+
_stagingPath(id) {
|
|
242
|
+
return _path_1.default.join(this.layout.staging, `${id}.part`);
|
|
243
|
+
}
|
|
244
|
+
_tarStagingPath(id) {
|
|
245
|
+
return _path_1.default.join(this.layout.staging, `${id}.tar.part`);
|
|
246
|
+
}
|
|
247
|
+
_treeStagingPath(id) {
|
|
248
|
+
return _path_1.default.join(this.layout.staging, `${id}.tree`);
|
|
249
|
+
}
|
|
250
|
+
_trashTreePath(id) {
|
|
251
|
+
return _path_1.default.join(this.layout.trash, `${id}.tree`);
|
|
252
|
+
}
|
|
253
|
+
/** The visible state of one top-level directory path, without following links. */
|
|
254
|
+
_rootTreeState(name) {
|
|
255
|
+
return (0, tree_fs_js_1.inspectTreePath)(this._finalPath(name), this.layout.root, this.storage);
|
|
256
|
+
}
|
|
257
|
+
_sessionPath(id) {
|
|
258
|
+
return _path_1.default.join(this.layout.sessions, `${id}.json`);
|
|
259
|
+
}
|
|
260
|
+
_journalPath(id) {
|
|
261
|
+
return _path_1.default.join(this.layout.journals, `${id}.json`);
|
|
262
|
+
}
|
|
263
|
+
_recordPath(id) {
|
|
264
|
+
return _path_1.default.join(this.layout.commits, `${id}.json`);
|
|
265
|
+
}
|
|
266
|
+
_finalPath(name) {
|
|
267
|
+
return _path_1.default.join(this.layout.root, name);
|
|
268
|
+
}
|
|
269
|
+
_publicationPath(name) {
|
|
270
|
+
return _path_1.default.join(this.layout.publications, name);
|
|
271
|
+
}
|
|
272
|
+
async _assertLayout() {
|
|
273
|
+
for (const directory of (0, layout_js_1.protectedDirectories)(this.layout)) {
|
|
274
|
+
await (0, layout_js_1.assertSafeDirectory)(directory, this.storage);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Inspects one top-level path without following or trusting symlinks. */
|
|
278
|
+
_rootPathState(name) {
|
|
279
|
+
const filePath = this._finalPath(name);
|
|
280
|
+
return (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, async () => {
|
|
281
|
+
let stat;
|
|
282
|
+
try {
|
|
283
|
+
stat = await this.storage.lstat(filePath);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
if ((0, error_code_js_1.isMissing)(err))
|
|
287
|
+
return { present: false, stat: null };
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
291
|
+
return { present: true, stat: null };
|
|
292
|
+
return { present: true, stat };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async _syncRoot() {
|
|
296
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => syncDirectory(this.layout.root, this.storage));
|
|
297
|
+
}
|
|
298
|
+
async _safeFileOrAbsent(filePath, directory) {
|
|
299
|
+
try {
|
|
300
|
+
return await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, () => (0, layout_js_1.assertSafeFile)(filePath, this.storage));
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
if ((0, error_code_js_1.isMissing)(err))
|
|
304
|
+
return null;
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async _readRecordOrAbsent(recordPath) {
|
|
309
|
+
const stat = await this._safeFileOrAbsent(recordPath, this.layout.commits);
|
|
310
|
+
if (!stat)
|
|
311
|
+
return null;
|
|
312
|
+
return (0, commit_journal_js_1.assertCommitRecord)(await (0, atomic_file_js_1.readJson)(recordPath, this.storage, commit_journal_js_1.MAX_COMMIT_METADATA_BYTES));
|
|
313
|
+
}
|
|
314
|
+
async _writeRecord(record) {
|
|
315
|
+
const recordPath = this._recordPath(record.transferId);
|
|
316
|
+
const existing = await this._readRecordOrAbsent(recordPath);
|
|
317
|
+
if (existing) {
|
|
318
|
+
if (!recordsEqual(existing, record))
|
|
319
|
+
throw storageError('Conflicting commit record');
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
await (0, atomic_file_js_1.writeAtomic)(recordPath, b4a_1.default.from(JSON.stringify(record)), this.storage);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
/** Persists a sidecar transition over its own previous durable content. */
|
|
326
|
+
async _rewriteRecord(record) {
|
|
327
|
+
(0, commit_journal_js_1.assertCommitRecord)(record);
|
|
328
|
+
await (0, atomic_file_js_1.writeAtomic)(this._recordPath(record.transferId), b4a_1.default.from(JSON.stringify(record)), this.storage);
|
|
329
|
+
}
|
|
330
|
+
async _removeFile(filePath, directory) {
|
|
331
|
+
let removed = false;
|
|
332
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, async () => {
|
|
333
|
+
try {
|
|
334
|
+
await (0, layout_js_1.assertSafeFile)(filePath, this.storage);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if ((0, error_code_js_1.isMissing)(err))
|
|
338
|
+
return;
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
await this.storage.unlink(filePath);
|
|
342
|
+
removed = true;
|
|
343
|
+
});
|
|
344
|
+
if (removed) {
|
|
345
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(directory, this.storage, () => syncDirectory(directory, this.storage));
|
|
346
|
+
}
|
|
347
|
+
return removed;
|
|
348
|
+
}
|
|
349
|
+
async _removeManagedFinalOrAbsent(record) {
|
|
350
|
+
let removed = false;
|
|
351
|
+
let preservedPath = false;
|
|
352
|
+
const finalPath = this._finalPath(record.name);
|
|
353
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, async () => {
|
|
354
|
+
let stat;
|
|
355
|
+
try {
|
|
356
|
+
stat = await this.storage.lstat(finalPath);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
if ((0, error_code_js_1.isMissing)(err))
|
|
360
|
+
return;
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
if (stat.isDirectory()) {
|
|
364
|
+
try {
|
|
365
|
+
await this.storage.rmdir(finalPath);
|
|
366
|
+
removed = true;
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if ((0, error_code_js_1.errorCode)(err) === 'ENOTEMPTY' || (0, error_code_js_1.errorCode)(err) === 'EEXIST') {
|
|
370
|
+
preservedPath = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
await this.storage.unlink(finalPath);
|
|
378
|
+
removed = true;
|
|
379
|
+
});
|
|
380
|
+
if (removed) {
|
|
381
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => syncDirectory(this.layout.root, this.storage));
|
|
382
|
+
}
|
|
383
|
+
return { removed, preservedPath };
|
|
384
|
+
}
|
|
385
|
+
async _linkJournal(id, journal, attempt) {
|
|
386
|
+
const journalPath = this._journalPath(id);
|
|
387
|
+
const temporary = _path_1.default.join(this.layout.journals, `.${id}.${attempt}.tmp`);
|
|
388
|
+
const bytes = b4a_1.default.from(JSON.stringify((0, commit_journal_js_1.serializeJournal)(journal)));
|
|
389
|
+
try {
|
|
390
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.journals, this.storage, async () => {
|
|
391
|
+
const handle = await (0, layout_js_1.openSafeRegularFile)(temporary, 'create', this.storage);
|
|
392
|
+
try {
|
|
393
|
+
await writeAll(handle, bytes);
|
|
394
|
+
await handle.sync();
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
await handle.close();
|
|
398
|
+
}
|
|
399
|
+
await this.storage.link(temporary, journalPath);
|
|
400
|
+
this.journalAttempts.set(id, attempt);
|
|
401
|
+
await syncDirectory(this.layout.journals, this.storage);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await this._removeFile(temporary, this.layout.journals).catch(() => { });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async _writeJournal(record, sourceStagingIdentity) {
|
|
409
|
+
const journalPath = this._journalPath(record.transferId);
|
|
410
|
+
if (await this._safeFileOrAbsent(journalPath, this.layout.journals)) {
|
|
411
|
+
let existing;
|
|
412
|
+
try {
|
|
413
|
+
existing = await this._readJournal(record.transferId);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (!(err instanceof commit_journal_js_1.CorruptJournalError))
|
|
417
|
+
throw err;
|
|
418
|
+
await this.retireCorruptJournal(record.transferId);
|
|
419
|
+
}
|
|
420
|
+
if (existing) {
|
|
421
|
+
if (existing.version !== commit_journal_js_1.JOURNAL_VERSION ||
|
|
422
|
+
existing.state !== 'committing' ||
|
|
423
|
+
!recordsEqual(existing.record, record) ||
|
|
424
|
+
!(0, commit_journal_js_1.identitiesEqual)(existing.sourceStagingIdentity, sourceStagingIdentity) ||
|
|
425
|
+
this.journalAttempts.get(record.transferId) !== existing.attemptId) {
|
|
426
|
+
throw storageError('Foreign commit journal already exists');
|
|
427
|
+
}
|
|
428
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.journals, this.storage, () => syncDirectory(this.layout.journals, this.storage));
|
|
429
|
+
return existing.attemptId;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const id = attemptId();
|
|
433
|
+
await this._linkJournal(record.transferId, {
|
|
434
|
+
version: commit_journal_js_1.JOURNAL_VERSION,
|
|
435
|
+
state: 'committing',
|
|
436
|
+
attemptId: id,
|
|
437
|
+
sourceStagingIdentity,
|
|
438
|
+
record
|
|
439
|
+
}, id);
|
|
440
|
+
return id;
|
|
441
|
+
}
|
|
442
|
+
/** A replacement or directory commit always starts a fresh attempt; leftovers belong to recovery. */
|
|
443
|
+
async _writeFreshJournal(journal) {
|
|
444
|
+
const id = journal.transferId;
|
|
445
|
+
if (await this._safeFileOrAbsent(this._journalPath(id), this.layout.journals)) {
|
|
446
|
+
let existing = null;
|
|
447
|
+
try {
|
|
448
|
+
existing = await this._readJournal(id);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (!(err instanceof commit_journal_js_1.CorruptJournalError))
|
|
452
|
+
throw err;
|
|
453
|
+
await this.retireCorruptJournal(id);
|
|
454
|
+
}
|
|
455
|
+
if (existing)
|
|
456
|
+
throw storageError('Foreign commit journal already exists');
|
|
457
|
+
}
|
|
458
|
+
await this._linkJournal(id, journal, journal.attemptId);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Records the boundary an attempt has already passed. The observable
|
|
462
|
+
* filesystem state stays authoritative during recovery; the phase is the
|
|
463
|
+
* durable intent trail the journal must carry.
|
|
464
|
+
*/
|
|
465
|
+
async _setReplacementPhase(journal, phase) {
|
|
466
|
+
const next = { ...journal, phase };
|
|
467
|
+
await (0, atomic_file_js_1.writeAtomic)(this._journalPath(journal.transferId), b4a_1.default.from(JSON.stringify((0, commit_journal_js_1.serializeJournal)(next))), this.storage);
|
|
468
|
+
return next;
|
|
469
|
+
}
|
|
470
|
+
async retireCorruptJournal(id) {
|
|
471
|
+
if (!(0, commit_journal_js_1.isHex)(id))
|
|
472
|
+
throw storageError('Invalid corrupt journal ID');
|
|
473
|
+
const journalPath = this._journalPath(id);
|
|
474
|
+
const quarantine = _path_1.default.join(this.layout.journals, `.${id}.corrupt-${attemptId()}`);
|
|
475
|
+
let retired = false;
|
|
476
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.journals, this.storage, async () => {
|
|
477
|
+
try {
|
|
478
|
+
await (0, layout_js_1.assertSafeFile)(journalPath, this.storage);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if ((0, error_code_js_1.isMissing)(err))
|
|
482
|
+
return;
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
await this.storage.rename(journalPath, quarantine);
|
|
486
|
+
retired = true;
|
|
487
|
+
});
|
|
488
|
+
if (retired) {
|
|
489
|
+
this.journalAttempts.delete(id);
|
|
490
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.journals, this.storage, () => syncDirectory(this.layout.journals, this.storage));
|
|
491
|
+
}
|
|
492
|
+
return retired;
|
|
493
|
+
}
|
|
494
|
+
async retireCorruptAttempt(id) {
|
|
495
|
+
if (!(0, commit_journal_js_1.isHex)(id))
|
|
496
|
+
throw storageError('Invalid corrupt journal ID');
|
|
497
|
+
const session = await this._safeFileOrAbsent(this._sessionPath(id), this.layout.sessions);
|
|
498
|
+
let removedStaging = false;
|
|
499
|
+
if (!session) {
|
|
500
|
+
removedStaging = await this._removeFile(this._stagingPath(id), this.layout.staging);
|
|
501
|
+
}
|
|
502
|
+
const retired = await this.retireCorruptJournal(id);
|
|
503
|
+
return { retired, removedStaging };
|
|
504
|
+
}
|
|
505
|
+
async _discardJournal(id, expectedAttemptId) {
|
|
506
|
+
const journal = await this._readJournal(id);
|
|
507
|
+
if (!journal) {
|
|
508
|
+
if (this.journalAttempts.get(id) === expectedAttemptId)
|
|
509
|
+
this.journalAttempts.delete(id);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
if (journal.attemptId !== expectedAttemptId)
|
|
513
|
+
return false;
|
|
514
|
+
const removed = await this._removeFile(this._journalPath(id), this.layout.journals);
|
|
515
|
+
if (removed && this.journalAttempts.get(id) === expectedAttemptId) {
|
|
516
|
+
this.journalAttempts.delete(id);
|
|
517
|
+
}
|
|
518
|
+
return removed;
|
|
519
|
+
}
|
|
520
|
+
async _markAttemptAborting(id, expectedAttemptId) {
|
|
521
|
+
const journal = await this._readJournal(id);
|
|
522
|
+
if (!journal || journal.attemptId !== expectedAttemptId) {
|
|
523
|
+
throw storageError('Commit journal changed before abort');
|
|
524
|
+
}
|
|
525
|
+
if (journal.state === 'aborting')
|
|
526
|
+
return journal;
|
|
527
|
+
const bytes = b4a_1.default.from(JSON.stringify({ ...(0, commit_journal_js_1.serializeJournal)(journal), state: 'aborting' }));
|
|
528
|
+
await (0, atomic_file_js_1.writeAtomic)(this._journalPath(id), bytes, this.storage);
|
|
529
|
+
const marked = await this._readJournal(id);
|
|
530
|
+
if (!marked ||
|
|
531
|
+
marked.version !== journal.version ||
|
|
532
|
+
marked.attemptId !== expectedAttemptId ||
|
|
533
|
+
marked.state !== 'aborting') {
|
|
534
|
+
throw storageError('Commit journal changed while marking abort');
|
|
535
|
+
}
|
|
536
|
+
return marked;
|
|
537
|
+
}
|
|
538
|
+
_linkStaging(stagingPath, finalPath, expectedIdentity, signal) {
|
|
539
|
+
return (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, async () => {
|
|
540
|
+
const staging = await (0, layout_js_1.assertSafeFile)(stagingPath, this.storage);
|
|
541
|
+
if (!(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), expectedIdentity)) {
|
|
542
|
+
throw storageError('Staging file changed before commit');
|
|
543
|
+
}
|
|
544
|
+
assertNotAborted(signal);
|
|
545
|
+
await this.storage.link(stagingPath, finalPath);
|
|
546
|
+
assertNotAborted(signal);
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
async _removeAbortedPublication(record, expectedIdentity) {
|
|
550
|
+
const errors = [];
|
|
551
|
+
try {
|
|
552
|
+
const finalPath = this._finalPath(record.name);
|
|
553
|
+
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
554
|
+
if (final && (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final), expectedIdentity)) {
|
|
555
|
+
await this._removeFile(finalPath, this.layout.root);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
errors.push(err);
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const recordPath = this._recordPath(record.transferId);
|
|
563
|
+
const sidecar = await this._readRecordOrAbsent(recordPath);
|
|
564
|
+
if (sidecar && recordsEqual(sidecar, record)) {
|
|
565
|
+
await this._removeFile(recordPath, this.layout.commits);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
errors.push(err);
|
|
570
|
+
}
|
|
571
|
+
if (errors.length)
|
|
572
|
+
throw new AggregateError(errors, 'Unable to roll back publication');
|
|
573
|
+
}
|
|
574
|
+
_reportCleanupPending(record, err) {
|
|
575
|
+
try {
|
|
576
|
+
this.logger?.warn?.('Committed artifact cleanup remains pending', {
|
|
577
|
+
fingerprint: record.uploaderFingerprint,
|
|
578
|
+
name: record.name,
|
|
579
|
+
code: (0, error_code_js_1.errorCode)(err),
|
|
580
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch { }
|
|
584
|
+
}
|
|
585
|
+
async _abortAttempt(record, expectedIdentity, attemptId, aborted) {
|
|
586
|
+
try {
|
|
587
|
+
await this._markAttemptAborting(record.transferId, attemptId);
|
|
588
|
+
await this._removeAbortedPublication(record, expectedIdentity);
|
|
589
|
+
await this._discardJournal(record.transferId, attemptId);
|
|
590
|
+
}
|
|
591
|
+
catch (cleanupError) {
|
|
592
|
+
throw new AggregateError([aborted, cleanupError], 'Unable to clean up aborted commit');
|
|
593
|
+
}
|
|
594
|
+
throw aborted;
|
|
595
|
+
}
|
|
596
|
+
async _removeOwnedFile(filePath, directory, identity) {
|
|
597
|
+
const stat = await this._safeFileOrAbsent(filePath, directory);
|
|
598
|
+
if (!stat || !(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(stat), identity))
|
|
599
|
+
return false;
|
|
600
|
+
return this._removeFile(filePath, directory);
|
|
601
|
+
}
|
|
602
|
+
async _matchesRecord(filePath, directory, record, syncFirst = false) {
|
|
603
|
+
const stat = await this._safeFileOrAbsent(filePath, directory);
|
|
604
|
+
if (!stat)
|
|
605
|
+
return false;
|
|
606
|
+
const result = await digestExactFile(filePath, record.size, this.storage, syncFirst);
|
|
607
|
+
return result !== null && (0, hash_js_1.digestMatches)(result.digest, b4a_1.default.from(record.sha256, 'hex'));
|
|
608
|
+
}
|
|
609
|
+
_recordFromSession(session, release) {
|
|
610
|
+
assertSession(session);
|
|
611
|
+
const base = {
|
|
612
|
+
name: session.name,
|
|
613
|
+
size: session.size,
|
|
614
|
+
sha256: toHex(session.digest),
|
|
615
|
+
committedAt: this.clock.now(),
|
|
616
|
+
uploaderFingerprint: fingerprint(session.ownerKey),
|
|
617
|
+
transferId: session.id,
|
|
618
|
+
...(release === undefined ? {} : { release })
|
|
619
|
+
};
|
|
620
|
+
return (0, commit_journal_js_1.assertCommitRecord)(session.kind === 'directory'
|
|
621
|
+
? {
|
|
622
|
+
version: commit_journal_js_1.DIRECTORY_COMMIT_VERSION,
|
|
623
|
+
kind: 'directory',
|
|
624
|
+
entryCount: session.entryCount,
|
|
625
|
+
...base
|
|
626
|
+
}
|
|
627
|
+
: { version: commit_journal_js_1.COMMIT_VERSION, ...base });
|
|
628
|
+
}
|
|
629
|
+
async inspect(name, offer, { replaceNames } = {}) {
|
|
630
|
+
await this._assertLayout();
|
|
631
|
+
(0, files_js_1.validateBasename)(name);
|
|
632
|
+
if ((0, files_js_1.isReservedHistoryName)(name)) {
|
|
633
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.INVALID_FILENAME, 'Reserved artifact name');
|
|
634
|
+
}
|
|
635
|
+
if (!offer || typeof offer !== 'object')
|
|
636
|
+
throw storageError('Invalid commit offer');
|
|
637
|
+
if (offer.name !== name)
|
|
638
|
+
throw storageError('Commit offer name mismatch');
|
|
639
|
+
(0, validation_js_1.assertSafeUint)(offer.size, 'commit offer size');
|
|
640
|
+
(0, validation_js_1.assertFixed32)(offer.digest, 'commit offer digest');
|
|
641
|
+
(0, validation_js_1.assertFixed32)(offer.transferId, 'commit offer transfer ID');
|
|
642
|
+
if (offer.kind !== undefined && offer.kind !== 'file' && offer.kind !== 'directory') {
|
|
643
|
+
throw storageError('Invalid commit offer kind');
|
|
644
|
+
}
|
|
645
|
+
if (offer.kind === 'directory')
|
|
646
|
+
(0, validation_js_1.assertSafeUint)(offer.entryCount, 'commit offer entry count');
|
|
647
|
+
const release = snapshotRelease(offer.release);
|
|
648
|
+
const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
|
|
649
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, name, () => this._inspect(name, offer, mutable, release));
|
|
650
|
+
}
|
|
651
|
+
async _inspect(name, offer, mutable, release) {
|
|
652
|
+
if (offer.kind === 'directory')
|
|
653
|
+
return this._inspectDirectory(name, offer);
|
|
654
|
+
const finalPath = this._finalPath(name);
|
|
655
|
+
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
656
|
+
if (!final)
|
|
657
|
+
return { status: 'AVAILABLE' };
|
|
658
|
+
const id = toHex(offer.transferId);
|
|
659
|
+
const record = await this._readRecordOrAbsent(this._recordPath(id));
|
|
660
|
+
if (record &&
|
|
661
|
+
record.name === name &&
|
|
662
|
+
record.size === offer.size &&
|
|
663
|
+
sameHex32(record.sha256, toHex(offer.digest)) &&
|
|
664
|
+
sameHex32(record.transferId, id) &&
|
|
665
|
+
releasesEqual(record.release, release) &&
|
|
666
|
+
(await this._matchesRecord(finalPath, this.layout.root, record))) {
|
|
667
|
+
return { status: 'ALREADY_COMMITTED', record };
|
|
668
|
+
}
|
|
669
|
+
if (!mutable.has(name))
|
|
670
|
+
return { status: 'FILE_EXISTS' };
|
|
671
|
+
const current = await this._managedCurrent(name);
|
|
672
|
+
if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(final))) {
|
|
673
|
+
return { status: 'FILE_EXISTS' };
|
|
674
|
+
}
|
|
675
|
+
const sameRelease = releasesEqual(current.record.release, release);
|
|
676
|
+
if (sameHex32(current.record.transferId, id) && !sameRelease) {
|
|
677
|
+
// One transfer ID names one authenticated upload; its release identity is immutable.
|
|
678
|
+
throw storageError('Release identity conflicts with the committed transfer');
|
|
679
|
+
}
|
|
680
|
+
if (sameRelease &&
|
|
681
|
+
current.record.size === offer.size &&
|
|
682
|
+
sameHex32(current.record.sha256, toHex(offer.digest))) {
|
|
683
|
+
return { status: 'ALREADY_COMMITTED', record: current.record };
|
|
684
|
+
}
|
|
685
|
+
return { status: 'REPLACEABLE', record: current.record };
|
|
686
|
+
}
|
|
687
|
+
async _inspectDirectory(name, offer) {
|
|
688
|
+
if ((await this._rootTreeState(name)) === 'MISSING') {
|
|
689
|
+
const occupied = await this._safeFileOrAbsent(this._finalPath(name), this.layout.root);
|
|
690
|
+
return occupied ? { status: 'FILE_EXISTS' } : { status: 'AVAILABLE' };
|
|
691
|
+
}
|
|
692
|
+
const id = toHex(offer.transferId);
|
|
693
|
+
const record = await this._readRecordOrAbsent(this._recordPath(id));
|
|
694
|
+
if (!record ||
|
|
695
|
+
record.kind !== 'directory' ||
|
|
696
|
+
record.name !== name ||
|
|
697
|
+
record.size !== offer.size ||
|
|
698
|
+
record.entryCount !== offer.entryCount ||
|
|
699
|
+
!sameHex32(record.sha256, toHex(offer.digest)) ||
|
|
700
|
+
!sameHex32(record.transferId, id)) {
|
|
701
|
+
return { status: 'FILE_EXISTS' };
|
|
702
|
+
}
|
|
703
|
+
let digested;
|
|
704
|
+
try {
|
|
705
|
+
digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
if (error instanceof errors_js_1.SwarmDeployError &&
|
|
709
|
+
(error.code === errors_js_1.ERRORS.INVALID_FILENAME || error.code === errors_js_1.ERRORS.PROTOCOL_INVALID)) {
|
|
710
|
+
return { status: 'FILE_EXISTS' };
|
|
711
|
+
}
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
if (digested.entryCount !== record.entryCount ||
|
|
715
|
+
digested.payloadBytes !== record.size ||
|
|
716
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
717
|
+
return { status: 'FILE_EXISTS' };
|
|
718
|
+
}
|
|
719
|
+
return { status: 'ALREADY_COMMITTED', record };
|
|
720
|
+
}
|
|
721
|
+
/** The managed record whose verified content currently owns a visible name. */
|
|
722
|
+
async _managedCurrent(name) {
|
|
723
|
+
const finalPath = this._finalPath(name);
|
|
724
|
+
const matches = (await this._scanRecords()).filter((record) => record.name === name);
|
|
725
|
+
if (matches.length === 0)
|
|
726
|
+
return null;
|
|
727
|
+
if (matches.length > 1)
|
|
728
|
+
throw storageError('Duplicate managed commit filename');
|
|
729
|
+
const record = matches[0];
|
|
730
|
+
const digested = await digestExactFile(finalPath, record.size, this.storage);
|
|
731
|
+
if (!digested || !(0, hash_js_1.digestMatches)(digested.digest, b4a_1.default.from(record.sha256, 'hex')))
|
|
732
|
+
return null;
|
|
733
|
+
return { record, identity: digested.identity };
|
|
734
|
+
}
|
|
735
|
+
_assertSessionMatchesRecord(session, record, message) {
|
|
736
|
+
assertSession(session);
|
|
737
|
+
if (session.name !== record.name ||
|
|
738
|
+
session.size !== record.size ||
|
|
739
|
+
toHex(session.digest) !== record.sha256 ||
|
|
740
|
+
fingerprint(session.ownerKey) !== record.uploaderFingerprint ||
|
|
741
|
+
(record.kind === 'directory' &&
|
|
742
|
+
(session.kind !== 'directory' || session.entryCount !== record.entryCount))) {
|
|
743
|
+
throw new commit_journal_js_1.CorruptJournalError(message);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async _validateAttemptLeftovers(id, journal, sessionStore) {
|
|
747
|
+
const { record, sourceStagingIdentity } = journal;
|
|
748
|
+
const sessionPath = this._sessionPath(id);
|
|
749
|
+
const stagingPath = this._stagingPath(id);
|
|
750
|
+
const sessionFile = await this._safeFileOrAbsent(sessionPath, this.layout.sessions);
|
|
751
|
+
const staging = await this._safeFileOrAbsent(stagingPath, this.layout.staging);
|
|
752
|
+
if (sessionFile) {
|
|
753
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
754
|
+
throw storageError('Session store cannot validate commit cleanup');
|
|
755
|
+
}
|
|
756
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
757
|
+
this._assertSessionMatchesRecord(session, record, 'Commit journal does not match verified session');
|
|
758
|
+
}
|
|
759
|
+
if (staging &&
|
|
760
|
+
(!(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), sourceStagingIdentity) ||
|
|
761
|
+
!(await this._matchesRecord(stagingPath, this.layout.staging, record)))) {
|
|
762
|
+
throw storageError('Staging file does not match commit journal');
|
|
763
|
+
}
|
|
764
|
+
return { sessionFile, staging };
|
|
765
|
+
}
|
|
766
|
+
commit(session, { retentionManager = null, signal = null, replaceNames, release = null, deferPostCommitRetention = false } = {}) {
|
|
767
|
+
try {
|
|
768
|
+
assertSession(session);
|
|
769
|
+
const releaseSnapshot = snapshotRelease(release);
|
|
770
|
+
if (retentionManager !== null &&
|
|
771
|
+
(typeof retentionManager.run !== 'function' ||
|
|
772
|
+
typeof retentionManager.afterCommit !== 'function' ||
|
|
773
|
+
typeof retentionManager._runUnlocked !== 'function' ||
|
|
774
|
+
typeof retentionManager._afterCommitUnlocked !== 'function')) {
|
|
775
|
+
return Promise.reject(storageError('Invalid retention manager'));
|
|
776
|
+
}
|
|
777
|
+
if (signal !== null && (typeof signal !== 'object' || typeof signal.aborted !== 'boolean')) {
|
|
778
|
+
return Promise.reject(storageError('Invalid commit abort signal'));
|
|
779
|
+
}
|
|
780
|
+
const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
|
|
781
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, session.name, () => (0, root_coordinator_js_1.withRootLease)(this.layout.root, () => this._commit(session, retentionManager, signal, mutable, releaseSnapshot, deferPostCommitRetention === true)));
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
return Promise.reject(error);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async _commit(session, retentionManager, signal, mutable = new Set(), release, deferPostCommitRetention = false) {
|
|
788
|
+
assertNotAborted(signal);
|
|
789
|
+
await this._assertLayout();
|
|
790
|
+
const record = this._recordFromSession(session, release);
|
|
791
|
+
if ((0, files_js_1.isReservedHistoryName)(record.name)) {
|
|
792
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.INVALID_FILENAME, 'Reserved artifact name');
|
|
793
|
+
}
|
|
794
|
+
if (session.kind === 'directory') {
|
|
795
|
+
return this._commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention);
|
|
796
|
+
}
|
|
797
|
+
const stagingPath = this._stagingPath(record.transferId);
|
|
798
|
+
const finalPath = this._finalPath(record.name);
|
|
799
|
+
const stagingDigest = await digestExactFile(stagingPath, record.size, this.storage, true);
|
|
800
|
+
if (stagingDigest === null ||
|
|
801
|
+
!(0, hash_js_1.digestMatches)(stagingDigest.digest, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
802
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Staging file checksum mismatch');
|
|
803
|
+
}
|
|
804
|
+
if (mutable.has(record.name)) {
|
|
805
|
+
const plan = await this._planReplacement(record);
|
|
806
|
+
if (plan.mode === 'idempotent') {
|
|
807
|
+
await this._removeFile(this._sessionPath(record.transferId), this.layout.sessions);
|
|
808
|
+
await this._removeFile(stagingPath, this.layout.staging);
|
|
809
|
+
return plan.record;
|
|
810
|
+
}
|
|
811
|
+
if (plan.mode === 'replace') {
|
|
812
|
+
return this._replace(record, stagingDigest.identity, plan, retentionManager, signal, deferPostCommitRetention);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (retentionManager) {
|
|
816
|
+
await retentionManager._runUnlocked({ incomingBytes: record.size, trigger: 'commit' });
|
|
817
|
+
}
|
|
818
|
+
assertNotAborted(signal);
|
|
819
|
+
const journalAttemptId = await this._writeJournal(record, stagingDigest.identity);
|
|
820
|
+
let linked = false;
|
|
821
|
+
let linearized = false;
|
|
822
|
+
try {
|
|
823
|
+
assertNotAborted(signal);
|
|
824
|
+
await this._linkStaging(stagingPath, finalPath, stagingDigest.identity, signal);
|
|
825
|
+
linked = true;
|
|
826
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => syncDirectory(this.layout.root, this.storage));
|
|
827
|
+
assertNotAborted(signal);
|
|
828
|
+
await this._writeRecord(record);
|
|
829
|
+
linearized = true;
|
|
830
|
+
assertNotAborted(signal);
|
|
831
|
+
await this._removeFile(this._sessionPath(record.transferId), this.layout.sessions);
|
|
832
|
+
assertNotAborted(signal);
|
|
833
|
+
await this._removeFile(stagingPath, this.layout.staging);
|
|
834
|
+
await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
|
|
835
|
+
assertNotAborted(signal);
|
|
836
|
+
await this._discardJournal(record.transferId, journalAttemptId);
|
|
837
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
838
|
+
await retentionManager._afterCommitUnlocked();
|
|
839
|
+
}
|
|
840
|
+
return record;
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
if (linearized) {
|
|
844
|
+
this._reportCleanupPending(record, err);
|
|
845
|
+
return record;
|
|
846
|
+
}
|
|
847
|
+
if ((0, error_code_js_1.errorCode)(err) === errors_js_1.ERRORS.ABORTED) {
|
|
848
|
+
return this._abortAttempt(record, stagingDigest.identity, journalAttemptId, err);
|
|
849
|
+
}
|
|
850
|
+
if (!linked && (0, error_code_js_1.errorCode)(err) !== 'EEXIST') {
|
|
851
|
+
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
852
|
+
linked =
|
|
853
|
+
!!final &&
|
|
854
|
+
(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final), stagingDigest.identity) &&
|
|
855
|
+
(await this._matchesRecord(finalPath, this.layout.root, record));
|
|
856
|
+
}
|
|
857
|
+
if (linked) {
|
|
858
|
+
try {
|
|
859
|
+
await this._removeAbortedPublication(record, stagingDigest.identity);
|
|
860
|
+
await this._discardJournal(record.transferId, journalAttemptId);
|
|
861
|
+
}
|
|
862
|
+
catch (cleanupError) {
|
|
863
|
+
throw new AggregateError([err, cleanupError], 'Unable to roll back failed publication');
|
|
864
|
+
}
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
await this._discardJournal(record.transferId, journalAttemptId);
|
|
869
|
+
}
|
|
870
|
+
catch (cleanupCause) {
|
|
871
|
+
throw Object.assign(commitError('Unable to create final artifact', err), { cleanupCause });
|
|
872
|
+
}
|
|
873
|
+
if ((0, error_code_js_1.errorCode)(err) === 'EEXIST') {
|
|
874
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_EXISTS, 'Destination already exists', err);
|
|
875
|
+
}
|
|
876
|
+
throw commitError('Unable to create final artifact', err);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Publishes one verified staging tree by rename. Directory artifacts are
|
|
881
|
+
* create-only: a configured mutable name, an occupied destination, and a
|
|
882
|
+
* kind change are all existing-name conflicts.
|
|
883
|
+
*/
|
|
884
|
+
async _commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention = false) {
|
|
885
|
+
if (mutable.has(record.name)) {
|
|
886
|
+
throw existsError('Directory artifacts cannot be replaced');
|
|
887
|
+
}
|
|
888
|
+
const treePath = this._treeStagingPath(record.transferId);
|
|
889
|
+
const finalPath = this._finalPath(record.name);
|
|
890
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
|
|
891
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Verified staging tree is missing');
|
|
892
|
+
}
|
|
893
|
+
const digested = await (0, tree_fs_js_1.digestTree)(treePath, this.storage);
|
|
894
|
+
if (digested.entryCount !== record.entryCount ||
|
|
895
|
+
digested.payloadBytes !== record.size ||
|
|
896
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
897
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Staging tree checksum mismatch');
|
|
898
|
+
}
|
|
899
|
+
const stagingTreeIdentity = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.lstat(treePath)));
|
|
900
|
+
for (const existing of await this._scanRecords()) {
|
|
901
|
+
if (existing.name === record.name)
|
|
902
|
+
throw existsError('Destination already exists');
|
|
903
|
+
}
|
|
904
|
+
if ((await this._rootTreeState(record.name)) !== 'MISSING') {
|
|
905
|
+
throw existsError('Destination already exists');
|
|
906
|
+
}
|
|
907
|
+
if (retentionManager) {
|
|
908
|
+
await retentionManager._runUnlocked({ incomingBytes: record.size, trigger: 'commit' });
|
|
909
|
+
}
|
|
910
|
+
assertNotAborted(signal);
|
|
911
|
+
const attempt = attemptId();
|
|
912
|
+
let journal = {
|
|
913
|
+
version: commit_journal_js_1.DIRECTORY_JOURNAL_VERSION,
|
|
914
|
+
intent: 'create-directory',
|
|
915
|
+
state: 'committing',
|
|
916
|
+
phase: 'journaled',
|
|
917
|
+
transferId: record.transferId,
|
|
918
|
+
attemptId: attempt,
|
|
919
|
+
name: record.name,
|
|
920
|
+
stagingTreeName: `${record.transferId}.tree`,
|
|
921
|
+
stagingTreeIdentity,
|
|
922
|
+
record
|
|
923
|
+
};
|
|
924
|
+
await this._writeFreshJournal(journal);
|
|
925
|
+
let linearized = false;
|
|
926
|
+
try {
|
|
927
|
+
assertNotAborted(signal);
|
|
928
|
+
if ((await this._rootTreeState(record.name)) !== 'MISSING') {
|
|
929
|
+
throw existsError('Destination already exists');
|
|
930
|
+
}
|
|
931
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(treePath, finalPath)));
|
|
932
|
+
await this._syncRoot();
|
|
933
|
+
journal = await this._setDirectoryPhase(journal, 'renamed');
|
|
934
|
+
assertNotAborted(signal);
|
|
935
|
+
await this._writeRecord(record);
|
|
936
|
+
linearized = true;
|
|
937
|
+
journal = await this._setDirectoryPhase(journal, 'sidecar');
|
|
938
|
+
await this._removeFile(this._sessionPath(record.transferId), this.layout.sessions);
|
|
939
|
+
await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
|
|
940
|
+
await this._discardJournal(record.transferId, attempt);
|
|
941
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
942
|
+
await retentionManager._afterCommitUnlocked();
|
|
943
|
+
}
|
|
944
|
+
return record;
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
if (linearized) {
|
|
948
|
+
this._reportCleanupPending(record, err);
|
|
949
|
+
return record;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
await this._rollbackDirectory(journal);
|
|
953
|
+
}
|
|
954
|
+
catch (cleanupError) {
|
|
955
|
+
throw new AggregateError([err, cleanupError], 'Unable to roll back directory commit');
|
|
956
|
+
}
|
|
957
|
+
throw err;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async _setDirectoryPhase(journal, phase) {
|
|
961
|
+
const next = { ...journal, phase };
|
|
962
|
+
await (0, atomic_file_js_1.writeAtomic)(this._journalPath(journal.transferId), b4a_1.default.from(JSON.stringify((0, commit_journal_js_1.serializeJournal)(next))), this.storage);
|
|
963
|
+
return next;
|
|
964
|
+
}
|
|
965
|
+
/** Returns a proven renamed tree to staging; a foreign destination is preserved. */
|
|
966
|
+
async _rollbackDirectory(journal) {
|
|
967
|
+
const finalPath = this._finalPath(journal.name);
|
|
968
|
+
const treePath = this._treeStagingPath(journal.transferId);
|
|
969
|
+
if ((await this._rootTreeState(journal.name)) !== 'DIRECTORY') {
|
|
970
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) === 'DIRECTORY') {
|
|
971
|
+
await this._discardJournal(journal.transferId, journal.attemptId);
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const visible = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => this.storage.lstat(finalPath)));
|
|
976
|
+
if (!(0, commit_journal_js_1.identitiesEqual)(visible, journal.stagingTreeIdentity)) {
|
|
977
|
+
throw storageError('Foreign artifact at directory destination');
|
|
978
|
+
}
|
|
979
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'MISSING') {
|
|
980
|
+
throw storageError('Directory staging tree already exists');
|
|
981
|
+
}
|
|
982
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(finalPath, treePath)));
|
|
983
|
+
await this._syncRoot();
|
|
984
|
+
await this._discardJournal(journal.transferId, journal.attemptId);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Classifies a configured mutable name. Only an exact managed current record
|
|
988
|
+
* whose verified content owns the visible path may be replaced; every other
|
|
989
|
+
* occupied path is an existing-name conflict.
|
|
990
|
+
*/
|
|
991
|
+
async _planReplacement(record) {
|
|
992
|
+
if ((await this._rootTreeState(record.name)) === 'DIRECTORY') {
|
|
993
|
+
throw existsError('Destination already exists');
|
|
994
|
+
}
|
|
995
|
+
const state = await this._rootPathState(record.name);
|
|
996
|
+
if (!state.present)
|
|
997
|
+
return { mode: 'create' };
|
|
998
|
+
if (!state.stat)
|
|
999
|
+
throw existsError('Destination already exists');
|
|
1000
|
+
const current = await this._managedCurrent(record.name);
|
|
1001
|
+
if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(state.stat))) {
|
|
1002
|
+
throw existsError('Destination already exists');
|
|
1003
|
+
}
|
|
1004
|
+
const sameRelease = releasesEqual(current.record.release, record.release);
|
|
1005
|
+
if (current.record.transferId === record.transferId && !sameRelease) {
|
|
1006
|
+
// One transfer ID names one authenticated upload; its release identity is immutable.
|
|
1007
|
+
throw storageError('Release identity conflicts with the committed transfer');
|
|
1008
|
+
}
|
|
1009
|
+
if (sameContent(current.record, record) && sameRelease) {
|
|
1010
|
+
return { mode: 'idempotent', record: current.record };
|
|
1011
|
+
}
|
|
1012
|
+
return { mode: 'replace', oldRecord: current.record, finalIdentity: current.identity };
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* A repeated A→B→A sequence reuses a transfer ID that already owns a history
|
|
1016
|
+
* artifact of identical content. The superseded duplicate is dropped inside
|
|
1017
|
+
* the transaction so the returning content becomes the only copy.
|
|
1018
|
+
*/
|
|
1019
|
+
async _dedupHistory(record) {
|
|
1020
|
+
const name = (0, files_js_1.historyName)(record.transferId);
|
|
1021
|
+
const existing = await this._readRecordOrAbsent(this._recordPath(record.transferId));
|
|
1022
|
+
if (!existing)
|
|
1023
|
+
return null;
|
|
1024
|
+
if (existing.name !== name ||
|
|
1025
|
+
!sameContent(existing, record) ||
|
|
1026
|
+
!releasesEqual(existing.release, record.release)) {
|
|
1027
|
+
throw storageError('Conflicting commit record');
|
|
1028
|
+
}
|
|
1029
|
+
const state = await this._rootPathState(name);
|
|
1030
|
+
if (!state.present)
|
|
1031
|
+
return { name, identity: null };
|
|
1032
|
+
if (!state.stat)
|
|
1033
|
+
throw existsError('History destination already exists');
|
|
1034
|
+
const digested = await digestExactFile(this._finalPath(name), existing.size, this.storage);
|
|
1035
|
+
if (!digested || !(0, hash_js_1.digestMatches)(digested.digest, b4a_1.default.from(existing.sha256, 'hex'))) {
|
|
1036
|
+
throw existsError('History destination already exists');
|
|
1037
|
+
}
|
|
1038
|
+
return { name, identity: digested.identity };
|
|
1039
|
+
}
|
|
1040
|
+
async _replace(record, stagingIdentity, plan, retentionManager, signal, deferPostCommitRetention = false) {
|
|
1041
|
+
const { oldRecord, finalIdentity } = plan;
|
|
1042
|
+
const history = (0, files_js_1.historyName)(oldRecord.transferId);
|
|
1043
|
+
const newRecord = (0, commit_journal_js_1.assertCommitRecord)({
|
|
1044
|
+
...record,
|
|
1045
|
+
version: commit_journal_js_1.REPLACEMENT_COMMIT_VERSION,
|
|
1046
|
+
replaces: { name: record.name, transferId: oldRecord.transferId, historyName: history }
|
|
1047
|
+
});
|
|
1048
|
+
const finalPath = this._finalPath(record.name);
|
|
1049
|
+
const historyPath = this._finalPath(history);
|
|
1050
|
+
const stagingPath = this._stagingPath(record.transferId);
|
|
1051
|
+
const historyState = await this._rootPathState(history);
|
|
1052
|
+
if (historyState.present &&
|
|
1053
|
+
!(historyState.stat && (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(historyState.stat), finalIdentity))) {
|
|
1054
|
+
throw existsError('History destination already exists');
|
|
1055
|
+
}
|
|
1056
|
+
const dedup = await this._dedupHistory(newRecord);
|
|
1057
|
+
if (retentionManager) {
|
|
1058
|
+
await retentionManager._runUnlocked({ incomingBytes: newRecord.size, trigger: 'commit' });
|
|
1059
|
+
}
|
|
1060
|
+
assertNotAborted(signal);
|
|
1061
|
+
const pinned = await this._rootPathState(record.name);
|
|
1062
|
+
if (!pinned.stat || !(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(pinned.stat), finalIdentity)) {
|
|
1063
|
+
throw commitError('Managed artifact changed before replacement');
|
|
1064
|
+
}
|
|
1065
|
+
const attempt = attemptId();
|
|
1066
|
+
let journal = {
|
|
1067
|
+
version: commit_journal_js_1.REPLACEMENT_JOURNAL_VERSION,
|
|
1068
|
+
intent: 'replace',
|
|
1069
|
+
state: 'committing',
|
|
1070
|
+
phase: 'journaled',
|
|
1071
|
+
transferId: newRecord.transferId,
|
|
1072
|
+
attemptId: attempt,
|
|
1073
|
+
name: newRecord.name,
|
|
1074
|
+
historyName: history,
|
|
1075
|
+
publicationName: `${attempt}.part`,
|
|
1076
|
+
sourceStagingIdentity: stagingIdentity,
|
|
1077
|
+
finalIdentity,
|
|
1078
|
+
historyIdentity: finalIdentity,
|
|
1079
|
+
publicationIdentity: stagingIdentity,
|
|
1080
|
+
dedupHistoryName: dedup ? dedup.name : null,
|
|
1081
|
+
oldRecord,
|
|
1082
|
+
record: newRecord
|
|
1083
|
+
};
|
|
1084
|
+
const publicationPath = this._publicationPath(journal.publicationName);
|
|
1085
|
+
await this._writeFreshJournal(journal);
|
|
1086
|
+
let linearized = false;
|
|
1087
|
+
try {
|
|
1088
|
+
assertNotAborted(signal);
|
|
1089
|
+
if (dedup) {
|
|
1090
|
+
if (dedup.identity) {
|
|
1091
|
+
await this._removeOwnedFile(this._finalPath(dedup.name), this.layout.root, dedup.identity);
|
|
1092
|
+
}
|
|
1093
|
+
await this._removeFile(this._recordPath(newRecord.transferId), this.layout.commits);
|
|
1094
|
+
journal = await this._setReplacementPhase(journal, 'deduped');
|
|
1095
|
+
}
|
|
1096
|
+
assertNotAborted(signal);
|
|
1097
|
+
if (!(await this._rootPathState(history)).present) {
|
|
1098
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => this.storage.link(finalPath, historyPath));
|
|
1099
|
+
await this._syncRoot();
|
|
1100
|
+
}
|
|
1101
|
+
journal = await this._setReplacementPhase(journal, 'history-linked');
|
|
1102
|
+
assertNotAborted(signal);
|
|
1103
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.publications, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.link(stagingPath, publicationPath)));
|
|
1104
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.publications, this.storage, () => syncDirectory(this.layout.publications, this.storage));
|
|
1105
|
+
journal = await this._setReplacementPhase(journal, 'publication-linked');
|
|
1106
|
+
assertNotAborted(signal);
|
|
1107
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.publications, this.storage, () => this.storage.rename(publicationPath, finalPath)));
|
|
1108
|
+
await this._syncRoot();
|
|
1109
|
+
journal = await this._setReplacementPhase(journal, 'final-renamed');
|
|
1110
|
+
assertNotAborted(signal);
|
|
1111
|
+
await this._writeRecord(newRecord);
|
|
1112
|
+
linearized = true;
|
|
1113
|
+
journal = await this._setReplacementPhase(journal, 'current-sidecar');
|
|
1114
|
+
await this._rewriteRecord(historyRecord(oldRecord, history));
|
|
1115
|
+
journal = await this._setReplacementPhase(journal, 'history-sidecar');
|
|
1116
|
+
await this._removeFile(this._sessionPath(newRecord.transferId), this.layout.sessions);
|
|
1117
|
+
await this._removeFile(stagingPath, this.layout.staging);
|
|
1118
|
+
await this._removeFile(this._tarStagingPath(newRecord.transferId), this.layout.staging);
|
|
1119
|
+
await this._discardJournal(newRecord.transferId, attempt);
|
|
1120
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
1121
|
+
await retentionManager._afterCommitUnlocked();
|
|
1122
|
+
}
|
|
1123
|
+
return newRecord;
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
if (linearized) {
|
|
1127
|
+
this._reportCleanupPending(newRecord, err);
|
|
1128
|
+
return newRecord;
|
|
1129
|
+
}
|
|
1130
|
+
const aborted = (0, error_code_js_1.errorCode)(err) === errors_js_1.ERRORS.ABORTED;
|
|
1131
|
+
try {
|
|
1132
|
+
if (aborted)
|
|
1133
|
+
await this._markAttemptAborting(newRecord.transferId, attempt);
|
|
1134
|
+
await this._rollbackReplacement(journal);
|
|
1135
|
+
await this._discardJournal(newRecord.transferId, attempt);
|
|
1136
|
+
}
|
|
1137
|
+
catch (cleanupError) {
|
|
1138
|
+
throw new AggregateError([err, cleanupError], aborted
|
|
1139
|
+
? 'Unable to clean up aborted replacement'
|
|
1140
|
+
: 'Unable to roll back failed replacement');
|
|
1141
|
+
}
|
|
1142
|
+
throw err;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Restores the exact pinned old inode at the mutable name and removes only
|
|
1147
|
+
* transaction-owned history, publication, and sidecar paths. A foreign
|
|
1148
|
+
* destination is never overwritten or removed.
|
|
1149
|
+
*/
|
|
1150
|
+
async _rollbackReplacement(journal) {
|
|
1151
|
+
const { name, historyName: history, sourceStagingIdentity, finalIdentity } = journal;
|
|
1152
|
+
const finalPath = this._finalPath(name);
|
|
1153
|
+
const historyPath = this._finalPath(history);
|
|
1154
|
+
const publicationPath = this._publicationPath(journal.publicationName);
|
|
1155
|
+
const final = await this._rootPathState(name);
|
|
1156
|
+
const historyState = await this._rootPathState(history);
|
|
1157
|
+
const ownsHistory = historyState.present &&
|
|
1158
|
+
historyState.stat !== null &&
|
|
1159
|
+
(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(historyState.stat), finalIdentity);
|
|
1160
|
+
if (historyState.present && !ownsHistory) {
|
|
1161
|
+
throw storageError('Foreign artifact at replacement history name');
|
|
1162
|
+
}
|
|
1163
|
+
if (final.stat && (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final.stat), finalIdentity)) {
|
|
1164
|
+
if (ownsHistory)
|
|
1165
|
+
await this._removeFile(historyPath, this.layout.root);
|
|
1166
|
+
}
|
|
1167
|
+
else if ((final.stat && (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final.stat), sourceStagingIdentity)) ||
|
|
1168
|
+
!final.present) {
|
|
1169
|
+
if (!ownsHistory) {
|
|
1170
|
+
if (final.present)
|
|
1171
|
+
throw storageError('Replacement cannot restore the old artifact');
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => this.storage.rename(historyPath, finalPath));
|
|
1175
|
+
await this._syncRoot();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
throw storageError('Foreign artifact at replacement destination');
|
|
1180
|
+
}
|
|
1181
|
+
await this._removeOwnedFile(publicationPath, this.layout.publications, sourceStagingIdentity);
|
|
1182
|
+
const sidecar = await this._readRecordOrAbsent(this._recordPath(journal.record.transferId));
|
|
1183
|
+
if (sidecar && recordsEqual(sidecar, journal.record)) {
|
|
1184
|
+
await this._removeFile(this._recordPath(journal.record.transferId), this.layout.commits);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
_readJournal(id) {
|
|
1188
|
+
return (0, commit_journal_js_1.readCommitJournal)(id, this.layout, this.storage);
|
|
1189
|
+
}
|
|
1190
|
+
/** Every parsable v2 journal, used to explain transient duplicate names. */
|
|
1191
|
+
async _pendingReplacements() {
|
|
1192
|
+
const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.journals, this.storage, () => this.storage.readdir(this.layout.journals));
|
|
1193
|
+
const pending = [];
|
|
1194
|
+
for (const filename of names.filter(isCommitRecordName).sort()) {
|
|
1195
|
+
let journal;
|
|
1196
|
+
try {
|
|
1197
|
+
journal = await this._readJournal(filename.slice(0, -'.json'.length));
|
|
1198
|
+
}
|
|
1199
|
+
catch (err) {
|
|
1200
|
+
if (err instanceof commit_journal_js_1.CorruptJournalError)
|
|
1201
|
+
continue;
|
|
1202
|
+
throw err;
|
|
1203
|
+
}
|
|
1204
|
+
if ((0, commit_journal_js_1.isReplacementJournal)(journal))
|
|
1205
|
+
pending.push(journal);
|
|
1206
|
+
}
|
|
1207
|
+
return pending;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Finalizes or rolls back one v2 attempt. The durable new current sidecar is
|
|
1211
|
+
* the linearization point: before it the old content is restored, after it
|
|
1212
|
+
* the new content is preserved while history metadata and cleanup converge.
|
|
1213
|
+
*/
|
|
1214
|
+
async _convergeReplacement(id, journal, sessionStore, { forceAbort = false } = {}) {
|
|
1215
|
+
const { record, name, attemptId: attempt } = journal;
|
|
1216
|
+
const finalPath = this._finalPath(name);
|
|
1217
|
+
const newSidecar = await this._readRecordOrAbsent(this._recordPath(record.transferId));
|
|
1218
|
+
if (newSidecar && !recordsEqual(newSidecar, record)) {
|
|
1219
|
+
throw storageError('Commit sidecar does not match journal');
|
|
1220
|
+
}
|
|
1221
|
+
if (newSidecar) {
|
|
1222
|
+
const final = await this._rootPathState(name);
|
|
1223
|
+
if (!final.stat ||
|
|
1224
|
+
!(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final.stat), journal.sourceStagingIdentity) ||
|
|
1225
|
+
!(await this._matchesRecord(finalPath, this.layout.root, record))) {
|
|
1226
|
+
throw storageError('Linearized replacement does not own its final artifact');
|
|
1227
|
+
}
|
|
1228
|
+
await this._repairHistorySidecar(journal);
|
|
1229
|
+
await this._removeOwnedFile(this._publicationPath(journal.publicationName), this.layout.publications, journal.sourceStagingIdentity);
|
|
1230
|
+
await this._cleanupReplacementLeftovers(id, journal, sessionStore);
|
|
1231
|
+
await this._discardJournal(id, attempt);
|
|
1232
|
+
return { status: 'COMMITTED', record };
|
|
1233
|
+
}
|
|
1234
|
+
if (forceAbort && journal.state === 'committing') {
|
|
1235
|
+
const marked = await this._markAttemptAborting(id, attempt);
|
|
1236
|
+
if (!(0, commit_journal_js_1.isReplacementJournal)(marked))
|
|
1237
|
+
throw storageError('Commit journal changed before abort');
|
|
1238
|
+
journal = marked;
|
|
1239
|
+
}
|
|
1240
|
+
if (!(await this._assertReplacementRetryable(id, journal, sessionStore))) {
|
|
1241
|
+
throw new commit_journal_js_1.CorruptJournalError('Replacement journal does not own verified staging');
|
|
1242
|
+
}
|
|
1243
|
+
await this._rollbackReplacement(journal);
|
|
1244
|
+
await this._discardJournal(id, attempt);
|
|
1245
|
+
return { status: journal.state === 'aborting' ? 'ABORTED' : 'RESUMABLE', record };
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Startup recovery for one v2 attempt. Revocation of a still-live session
|
|
1249
|
+
* aborts the attempt first so the same rollback path converges.
|
|
1250
|
+
*/
|
|
1251
|
+
_recoverReplacementJournal(id, journal, sessionStore, isAuthorized) {
|
|
1252
|
+
let forceAbort = journal.state === 'aborting';
|
|
1253
|
+
if (isAuthorized && journal.state === 'committing') {
|
|
1254
|
+
if (!sessionStore || !(sessionStore.sessions instanceof Map)) {
|
|
1255
|
+
throw storageError('Session store cannot validate commit journal');
|
|
1256
|
+
}
|
|
1257
|
+
const session = sessionStore.sessions.get(id);
|
|
1258
|
+
if (session && !isAuthorized(session.ownerKey))
|
|
1259
|
+
forceAbort = true;
|
|
1260
|
+
}
|
|
1261
|
+
return this._convergeReplacement(id, journal, sessionStore, { forceAbort });
|
|
1262
|
+
}
|
|
1263
|
+
/** Writes or drops the history sidecar for an already linearized attempt. */
|
|
1264
|
+
async _repairHistorySidecar(journal) {
|
|
1265
|
+
const { oldRecord, historyName: history, finalIdentity } = journal;
|
|
1266
|
+
const expected = historyRecord(oldRecord, history);
|
|
1267
|
+
const sidecar = await this._readRecordOrAbsent(this._recordPath(oldRecord.transferId));
|
|
1268
|
+
if (sidecar && !recordsEqual(sidecar, expected) && !recordsEqual(sidecar, oldRecord)) {
|
|
1269
|
+
throw storageError('Replacement history sidecar does not match journal');
|
|
1270
|
+
}
|
|
1271
|
+
const state = await this._rootPathState(history);
|
|
1272
|
+
if (state.present &&
|
|
1273
|
+
(!state.stat || !(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(state.stat), finalIdentity))) {
|
|
1274
|
+
throw storageError('Foreign artifact at replacement history name');
|
|
1275
|
+
}
|
|
1276
|
+
if (!state.present) {
|
|
1277
|
+
const sidecarPath = this._recordPath(oldRecord.transferId);
|
|
1278
|
+
if (sidecar)
|
|
1279
|
+
await this._removeFile(sidecarPath, this.layout.commits);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (!(await this._matchesRecord(this._finalPath(history), this.layout.root, expected))) {
|
|
1283
|
+
throw storageError('Replacement history artifact does not match its record');
|
|
1284
|
+
}
|
|
1285
|
+
if (!sidecar || !recordsEqual(sidecar, expected))
|
|
1286
|
+
await this._rewriteRecord(expected);
|
|
1287
|
+
}
|
|
1288
|
+
async _cleanupReplacementLeftovers(id, journal, sessionStore) {
|
|
1289
|
+
const sessionFile = await this._safeFileOrAbsent(this._sessionPath(id), this.layout.sessions);
|
|
1290
|
+
if (sessionFile) {
|
|
1291
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1292
|
+
throw storageError('Session store cannot validate commit cleanup');
|
|
1293
|
+
}
|
|
1294
|
+
this._assertSessionMatchesRecord(await sessionStore.readVerified(b4a_1.default.from(id, 'hex')), journal.record, 'Commit journal does not match verified session');
|
|
1295
|
+
await this._removeFile(this._sessionPath(id), this.layout.sessions);
|
|
1296
|
+
}
|
|
1297
|
+
const stagingPath = this._stagingPath(id);
|
|
1298
|
+
const staging = await this._safeFileOrAbsent(stagingPath, this.layout.staging);
|
|
1299
|
+
if (staging) {
|
|
1300
|
+
if (!(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), journal.sourceStagingIdentity)) {
|
|
1301
|
+
throw storageError('Staging file does not match commit journal');
|
|
1302
|
+
}
|
|
1303
|
+
await this._removeFile(stagingPath, this.layout.staging);
|
|
1304
|
+
}
|
|
1305
|
+
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1306
|
+
}
|
|
1307
|
+
/** Proves the verified session and staging an aborted attempt must retain. */
|
|
1308
|
+
/**
|
|
1309
|
+
* Converges one directory attempt. The durable sidecar is the linearization
|
|
1310
|
+
* point: before it a proven renamed tree returns to staging, after it the
|
|
1311
|
+
* published tree is kept and only residue is removed. A destination that
|
|
1312
|
+
* cannot be proven is preserved and fails closed.
|
|
1313
|
+
*/
|
|
1314
|
+
async _recoverDirectoryJournal(id, journal, sessionStore, isAuthorized = null) {
|
|
1315
|
+
const { record, name, attemptId: attempt } = journal;
|
|
1316
|
+
const sidecar = await this._readRecordOrAbsent(this._recordPath(id));
|
|
1317
|
+
if (sidecar && !recordsEqual(sidecar, record)) {
|
|
1318
|
+
throw storageError('Commit sidecar does not match journal');
|
|
1319
|
+
}
|
|
1320
|
+
const state = await this._rootTreeState(name);
|
|
1321
|
+
if (state === 'UNMANAGED')
|
|
1322
|
+
throw storageError('Foreign artifact at directory destination');
|
|
1323
|
+
if (state === 'DIRECTORY') {
|
|
1324
|
+
const digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
|
|
1325
|
+
if (digested.entryCount !== record.entryCount ||
|
|
1326
|
+
digested.payloadBytes !== record.size ||
|
|
1327
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
1328
|
+
throw storageError('Published directory does not match its journal');
|
|
1329
|
+
}
|
|
1330
|
+
if (!sidecar && isAuthorized) {
|
|
1331
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1332
|
+
throw storageError('Session store cannot validate recovery state');
|
|
1333
|
+
}
|
|
1334
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1335
|
+
if (!isAuthorized(session.ownerKey)) {
|
|
1336
|
+
await this._rollbackDirectory(journal);
|
|
1337
|
+
return { status: 'RESUMABLE', record };
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (!sidecar)
|
|
1341
|
+
await this._writeRecord(record);
|
|
1342
|
+
await this._removeFile(this._sessionPath(id), this.layout.sessions);
|
|
1343
|
+
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1344
|
+
await this._discardJournal(id, attempt);
|
|
1345
|
+
return { status: 'COMMITTED', record };
|
|
1346
|
+
}
|
|
1347
|
+
if (sidecar)
|
|
1348
|
+
await this._removeFile(this._recordPath(id), this.layout.commits);
|
|
1349
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1350
|
+
throw storageError('Session store cannot validate recovery state');
|
|
1351
|
+
}
|
|
1352
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1353
|
+
this._assertSessionMatchesRecord(session, record, 'Directory journal does not match verified session');
|
|
1354
|
+
const treePath = this._treeStagingPath(id);
|
|
1355
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
|
|
1356
|
+
throw new commit_journal_js_1.CorruptJournalError('Directory journal does not own verified staging');
|
|
1357
|
+
}
|
|
1358
|
+
await this._discardJournal(id, attempt);
|
|
1359
|
+
return { status: 'RESUMABLE', record };
|
|
1360
|
+
}
|
|
1361
|
+
async _assertReplacementRetryable(id, journal, sessionStore) {
|
|
1362
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1363
|
+
throw storageError('Session store cannot validate commit journal');
|
|
1364
|
+
}
|
|
1365
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1366
|
+
this._assertSessionMatchesRecord(session, journal.record, 'Replacement journal does not match verified session');
|
|
1367
|
+
const stagingPath = this._stagingPath(id);
|
|
1368
|
+
const staging = await this._safeFileOrAbsent(stagingPath, this.layout.staging);
|
|
1369
|
+
if (!staging || !(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), journal.sourceStagingIdentity)) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
return this._matchesRecord(stagingPath, this.layout.staging, journal.record);
|
|
1373
|
+
}
|
|
1374
|
+
async recoverJournal(id, sessionStore, { isAuthorized = null } = {}) {
|
|
1375
|
+
await this._assertLayout();
|
|
1376
|
+
if (isAuthorized !== null && typeof isAuthorized !== 'function') {
|
|
1377
|
+
throw storageError('Invalid recovery authorization callback');
|
|
1378
|
+
}
|
|
1379
|
+
const journal = await this._readJournal(id);
|
|
1380
|
+
if (!journal)
|
|
1381
|
+
return { status: 'MISSING' };
|
|
1382
|
+
if ((0, commit_journal_js_1.isDirectoryJournal)(journal)) {
|
|
1383
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverDirectoryJournal(id, journal, sessionStore, isAuthorized));
|
|
1384
|
+
}
|
|
1385
|
+
if ((0, commit_journal_js_1.isReplacementJournal)(journal)) {
|
|
1386
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverReplacementJournal(id, journal, sessionStore, isAuthorized));
|
|
1387
|
+
}
|
|
1388
|
+
const { record, attemptId: journalAttemptId, sourceStagingIdentity } = journal;
|
|
1389
|
+
const finalPath = this._finalPath(record.name);
|
|
1390
|
+
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
1391
|
+
const sidecar = await this._readRecordOrAbsent(this._recordPath(id));
|
|
1392
|
+
if (final &&
|
|
1393
|
+
sidecar &&
|
|
1394
|
+
recordsEqual(sidecar, record) &&
|
|
1395
|
+
(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final), sourceStagingIdentity) &&
|
|
1396
|
+
(await this._matchesRecord(finalPath, this.layout.root, record))) {
|
|
1397
|
+
await this._removeFile(this._sessionPath(id), this.layout.sessions);
|
|
1398
|
+
await this._removeFile(this._stagingPath(id), this.layout.staging);
|
|
1399
|
+
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1400
|
+
await this._discardJournal(id, journalAttemptId);
|
|
1401
|
+
return { status: 'COMMITTED', record };
|
|
1402
|
+
}
|
|
1403
|
+
if (journal.state === 'aborting') {
|
|
1404
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1405
|
+
throw storageError('Session store cannot validate aborted commit journal');
|
|
1406
|
+
}
|
|
1407
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1408
|
+
if (session.name !== record.name ||
|
|
1409
|
+
session.size !== record.size ||
|
|
1410
|
+
toHex(session.digest) !== record.sha256 ||
|
|
1411
|
+
fingerprint(session.ownerKey) !== record.uploaderFingerprint) {
|
|
1412
|
+
throw new commit_journal_js_1.CorruptJournalError('Revoked commit journal does not match verified session');
|
|
1413
|
+
}
|
|
1414
|
+
const staging = await (0, layout_js_1.assertSafeFile)(this._stagingPath(id), this.storage);
|
|
1415
|
+
if (!(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), sourceStagingIdentity)) {
|
|
1416
|
+
throw new commit_journal_js_1.CorruptJournalError('Revoked commit staging does not match journal');
|
|
1417
|
+
}
|
|
1418
|
+
await this._removeAbortedPublication(record, sourceStagingIdentity);
|
|
1419
|
+
await this._discardJournal(id, journalAttemptId);
|
|
1420
|
+
return { status: 'ABORTED', record };
|
|
1421
|
+
}
|
|
1422
|
+
if (isAuthorized) {
|
|
1423
|
+
if (!sessionStore || !(sessionStore.sessions instanceof Map)) {
|
|
1424
|
+
throw storageError('Session store cannot validate commit journal');
|
|
1425
|
+
}
|
|
1426
|
+
const authorizedSession = sessionStore.sessions.get(id);
|
|
1427
|
+
if (authorizedSession && !isAuthorized(authorizedSession.ownerKey)) {
|
|
1428
|
+
await this._markAttemptAborting(id, journalAttemptId);
|
|
1429
|
+
return this.recoverJournal(id, sessionStore, { isAuthorized });
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (final) {
|
|
1433
|
+
const staging = await this._safeFileOrAbsent(this._stagingPath(id), this.layout.staging);
|
|
1434
|
+
const provenanced = (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final), sourceStagingIdentity) ||
|
|
1435
|
+
(staging && (0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(final), (0, commit_journal_js_1.fileIdentity)(staging)));
|
|
1436
|
+
if (!provenanced) {
|
|
1437
|
+
await this._discardJournal(id, journalAttemptId);
|
|
1438
|
+
return { status: 'FILE_EXISTS', record };
|
|
1439
|
+
}
|
|
1440
|
+
if (!(await this._matchesRecord(finalPath, this.layout.root, record))) {
|
|
1441
|
+
throw storageError('Final file does not match commit journal');
|
|
1442
|
+
}
|
|
1443
|
+
if (!sidecar) {
|
|
1444
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1445
|
+
if (session.name !== record.name ||
|
|
1446
|
+
session.size !== record.size ||
|
|
1447
|
+
toHex(session.digest) !== record.sha256 ||
|
|
1448
|
+
fingerprint(session.ownerKey) !== record.uploaderFingerprint) {
|
|
1449
|
+
throw new commit_journal_js_1.CorruptJournalError('Commit journal does not match verified session');
|
|
1450
|
+
}
|
|
1451
|
+
await this._writeRecord(record);
|
|
1452
|
+
}
|
|
1453
|
+
if (sidecar && !recordsEqual(sidecar, record)) {
|
|
1454
|
+
throw storageError('Commit sidecar does not match journal');
|
|
1455
|
+
}
|
|
1456
|
+
await this._removeFile(this._sessionPath(id), this.layout.sessions);
|
|
1457
|
+
await this._removeFile(this._stagingPath(id), this.layout.staging);
|
|
1458
|
+
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1459
|
+
await this._discardJournal(id, journalAttemptId);
|
|
1460
|
+
return { status: 'COMMITTED', record };
|
|
1461
|
+
}
|
|
1462
|
+
if (sidecar && !recordsEqual(sidecar, record)) {
|
|
1463
|
+
throw storageError('Commit sidecar does not match journal');
|
|
1464
|
+
}
|
|
1465
|
+
if (sidecar)
|
|
1466
|
+
await this._removeFile(this._recordPath(id), this.layout.commits);
|
|
1467
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1468
|
+
throw storageError('Session store cannot validate recovery state');
|
|
1469
|
+
}
|
|
1470
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1471
|
+
if (session.name !== record.name ||
|
|
1472
|
+
session.size !== record.size ||
|
|
1473
|
+
toHex(session.digest) !== record.sha256) {
|
|
1474
|
+
throw storageError('Verified session does not match commit journal');
|
|
1475
|
+
}
|
|
1476
|
+
const stagingPath = this._stagingPath(id);
|
|
1477
|
+
const staging = await this._safeFileOrAbsent(stagingPath, this.layout.staging);
|
|
1478
|
+
if (!staging || !(0, commit_journal_js_1.identitiesEqual)((0, commit_journal_js_1.fileIdentity)(staging), sourceStagingIdentity)) {
|
|
1479
|
+
throw new commit_journal_js_1.CorruptJournalError('Commit journal does not own verified staging');
|
|
1480
|
+
}
|
|
1481
|
+
if (!(await this._matchesRecord(stagingPath, this.layout.staging, record))) {
|
|
1482
|
+
throw storageError('Verified staging does not match commit journal');
|
|
1483
|
+
}
|
|
1484
|
+
await this._discardJournal(id, journalAttemptId);
|
|
1485
|
+
return { status: 'RESUMABLE', record };
|
|
1486
|
+
}
|
|
1487
|
+
async _scanRecords() {
|
|
1488
|
+
const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.commits, this.storage, () => this.storage.readdir(this.layout.commits));
|
|
1489
|
+
const records = [];
|
|
1490
|
+
for (const filename of names.filter(isCommitRecordName).sort()) {
|
|
1491
|
+
const id = filename.slice(0, -'.json'.length);
|
|
1492
|
+
const record = await this._readRecordOrAbsent(this._recordPath(id));
|
|
1493
|
+
if (!record)
|
|
1494
|
+
throw storageError('Commit record disappeared during enumeration');
|
|
1495
|
+
if (record.transferId !== id)
|
|
1496
|
+
throw storageError('Commit record ID does not match path');
|
|
1497
|
+
records.push(record);
|
|
1498
|
+
}
|
|
1499
|
+
return records;
|
|
1500
|
+
}
|
|
1501
|
+
async scrubRecords() {
|
|
1502
|
+
await this._assertLayout();
|
|
1503
|
+
const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.commits, this.storage, () => this.storage.readdir(this.layout.commits));
|
|
1504
|
+
const records = [];
|
|
1505
|
+
let deleted = 0;
|
|
1506
|
+
for (const filename of names.filter(isCommitRecordName).sort()) {
|
|
1507
|
+
const id = filename.slice(0, -'.json'.length);
|
|
1508
|
+
const recordPath = this._recordPath(id);
|
|
1509
|
+
let record = null;
|
|
1510
|
+
let invalid = false;
|
|
1511
|
+
try {
|
|
1512
|
+
const parsed = await (0, atomic_file_js_1.readJson)(recordPath, this.storage, commit_journal_js_1.MAX_COMMIT_METADATA_BYTES);
|
|
1513
|
+
try {
|
|
1514
|
+
record = (0, commit_journal_js_1.assertCommitRecord)(parsed);
|
|
1515
|
+
invalid = record.transferId !== id;
|
|
1516
|
+
}
|
|
1517
|
+
catch {
|
|
1518
|
+
invalid = true;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
if (!(err instanceof atomic_file_js_1.MetadataFormatError))
|
|
1523
|
+
throw err;
|
|
1524
|
+
invalid = true;
|
|
1525
|
+
}
|
|
1526
|
+
if (invalid) {
|
|
1527
|
+
await this._removeFile(recordPath, this.layout.commits);
|
|
1528
|
+
deleted++;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (!record)
|
|
1532
|
+
throw storageError('Commit record disappeared during scrub');
|
|
1533
|
+
records.push(record);
|
|
1534
|
+
}
|
|
1535
|
+
return { records: await this._resolveRecords(records), deleted };
|
|
1536
|
+
}
|
|
1537
|
+
async _resolveRecords(records) {
|
|
1538
|
+
const counts = new Map();
|
|
1539
|
+
for (const record of records)
|
|
1540
|
+
counts.set(record.name, (counts.get(record.name) ?? 0) + 1);
|
|
1541
|
+
let pending = null;
|
|
1542
|
+
const resolved = [];
|
|
1543
|
+
for (const record of records) {
|
|
1544
|
+
if ((counts.get(record.name) ?? 0) < 2) {
|
|
1545
|
+
resolved.push(record);
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
if (pending === null)
|
|
1549
|
+
pending = await this._pendingReplacements();
|
|
1550
|
+
const owner = pending.find((journal) => journal.name === record.name &&
|
|
1551
|
+
journal.oldRecord.transferId === record.transferId &&
|
|
1552
|
+
recordsEqual(journal.oldRecord, record));
|
|
1553
|
+
resolved.push(owner ? historyRecord(record, owner.historyName) : record);
|
|
1554
|
+
}
|
|
1555
|
+
const seen = new Set();
|
|
1556
|
+
for (const record of resolved) {
|
|
1557
|
+
if (seen.has(record.name))
|
|
1558
|
+
throw storageError('Duplicate managed commit filename');
|
|
1559
|
+
seen.add(record.name);
|
|
1560
|
+
}
|
|
1561
|
+
return resolved;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* A replacement makes the old sidecar the history record after its new
|
|
1565
|
+
* current sidecar is durable, so one name can briefly carry two records.
|
|
1566
|
+
* Only a journal-owned duplicate is tolerated, reported at the history name
|
|
1567
|
+
* that recovery will persist; anything else fails closed.
|
|
1568
|
+
*/
|
|
1569
|
+
async list() {
|
|
1570
|
+
await this._assertLayout();
|
|
1571
|
+
return this._resolveRecords(await this._scanRecords());
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Locates the durable record a caller is deleting. A pending replacement may
|
|
1575
|
+
* still carry the mutable name on the sidecar the caller knows as history.
|
|
1576
|
+
*/
|
|
1577
|
+
async _storedRecordFor(record) {
|
|
1578
|
+
const stored = await this._readRecordOrAbsent(this._recordPath(record.transferId));
|
|
1579
|
+
if (!stored)
|
|
1580
|
+
return null;
|
|
1581
|
+
if (recordsEqual(stored, record))
|
|
1582
|
+
return stored;
|
|
1583
|
+
if (stored.name !== record.name &&
|
|
1584
|
+
recordsEqual(historyRecord(stored, record.name), record) &&
|
|
1585
|
+
(await this._pendingReplacements()).some((journal) => journal.name === stored.name &&
|
|
1586
|
+
journal.historyName === record.name &&
|
|
1587
|
+
recordsEqual(journal.oldRecord, stored))) {
|
|
1588
|
+
return stored;
|
|
1589
|
+
}
|
|
1590
|
+
throw storageError('Commit record changed before deletion');
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Removes proven private trash trees left by an interrupted deletion. Only a
|
|
1594
|
+
* `<64-hex>.tree` entry is ours; anything else in the directory is left alone.
|
|
1595
|
+
*/
|
|
1596
|
+
async sweepTrash() {
|
|
1597
|
+
await this._assertLayout();
|
|
1598
|
+
const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.readdir(this.layout.trash));
|
|
1599
|
+
let removed = 0;
|
|
1600
|
+
for (const name of names.sort()) {
|
|
1601
|
+
if (!/^[0-9a-f]{64}\.tree$/.test(name))
|
|
1602
|
+
continue;
|
|
1603
|
+
if (await (0, tree_fs_js_1.removeTree)(_path_1.default.join(this.layout.trash, name), this.layout.trash, this.storage)) {
|
|
1604
|
+
removed++;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return removed;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Removes a managed directory by renaming it into the private trash first, so
|
|
1611
|
+
* the visible name disappears atomically, and only then removing the sidecar
|
|
1612
|
+
* and recursively deleting the trash tree.
|
|
1613
|
+
*/
|
|
1614
|
+
async _removeManagedTree(record) {
|
|
1615
|
+
const finalPath = this._finalPath(record.name);
|
|
1616
|
+
const trashPath = this._trashTreePath(record.transferId);
|
|
1617
|
+
const state = await this._rootTreeState(record.name);
|
|
1618
|
+
if (state === 'MISSING')
|
|
1619
|
+
return { removed: false, preservedPath: false };
|
|
1620
|
+
if (state === 'UNMANAGED')
|
|
1621
|
+
return { removed: false, preservedPath: true };
|
|
1622
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(trashPath, this.layout.trash, this.storage)) !== 'MISSING') {
|
|
1623
|
+
await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
|
|
1624
|
+
}
|
|
1625
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.rename(finalPath, trashPath)));
|
|
1626
|
+
await this._syncRoot();
|
|
1627
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1628
|
+
await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
|
|
1629
|
+
return { removed: true, preservedPath: false };
|
|
1630
|
+
}
|
|
1631
|
+
async delete(record) {
|
|
1632
|
+
await this._assertLayout();
|
|
1633
|
+
(0, commit_journal_js_1.assertCommitRecord)(record);
|
|
1634
|
+
if (!(await this._storedRecordFor(record)))
|
|
1635
|
+
return false;
|
|
1636
|
+
if (record.kind === 'directory') {
|
|
1637
|
+
const removed = await this._removeManagedTree(record);
|
|
1638
|
+
if (removed.preservedPath) {
|
|
1639
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
if (!removed.removed) {
|
|
1643
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1644
|
+
}
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
const finalPath = this._finalPath(record.name);
|
|
1648
|
+
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
1649
|
+
if (final)
|
|
1650
|
+
await this._removeFile(finalPath, this.layout.root);
|
|
1651
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
async purge(record, { preservePath = false } = {}) {
|
|
1655
|
+
await this._assertLayout();
|
|
1656
|
+
(0, commit_journal_js_1.assertCommitRecord)(record);
|
|
1657
|
+
if (!(await this._storedRecordFor(record)))
|
|
1658
|
+
return false;
|
|
1659
|
+
if (record.kind === 'directory') {
|
|
1660
|
+
if (preservePath) {
|
|
1661
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1662
|
+
return { purged: true, preservedPath: true };
|
|
1663
|
+
}
|
|
1664
|
+
const removed = await this._removeManagedTree(record);
|
|
1665
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1666
|
+
return { purged: true, preservedPath: removed.preservedPath };
|
|
1667
|
+
}
|
|
1668
|
+
let final;
|
|
1669
|
+
if (preservePath) {
|
|
1670
|
+
let preservedPath = false;
|
|
1671
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, async () => {
|
|
1672
|
+
try {
|
|
1673
|
+
await this.storage.lstat(this._finalPath(record.name));
|
|
1674
|
+
preservedPath = true;
|
|
1675
|
+
}
|
|
1676
|
+
catch (err) {
|
|
1677
|
+
if (!(0, error_code_js_1.isMissing)(err))
|
|
1678
|
+
throw err;
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
final = { removed: false, preservedPath };
|
|
1682
|
+
}
|
|
1683
|
+
else {
|
|
1684
|
+
final = await this._removeManagedFinalOrAbsent(record);
|
|
1685
|
+
}
|
|
1686
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1687
|
+
return { purged: true, preservedPath: final.preservedPath };
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
exports.CommitStore = CommitStore;
|
|
1691
|
+
//# sourceMappingURL=commit-store.js.map
|