@uns-kit/core 3.0.43 → 3.0.45
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/MIGRATIONS.md +37 -0
- package/README.md +14 -0
- package/dist/runtime/retained-store-examples/index-worker.manifest.json +22 -0
- package/dist/runtime/retained-store-examples/measurement-worker.manifest.json +22 -0
- package/dist/runtime/retained-store-guide.md +122 -0
- package/dist/runtime/retained-store-storage-contract.json +9 -0
- package/dist/runtime/retained-store-storage.d.mts +17 -0
- package/dist/runtime/retained-store-storage.mjs +493 -0
- package/dist/runtime/retained-store.d.ts +126 -0
- package/dist/runtime/retained-store.d.ts.map +1 -0
- package/dist/runtime/retained-store.js +373 -0
- package/dist/runtime/retained-store.js.map +1 -0
- package/package.json +10 -1
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
export const RESTORE_FILE = "retained-store-restore.json";
|
|
7
|
+
export const DECLARATION = "store-resources.json";
|
|
8
|
+
export const OPERATION = "store-operation.json";
|
|
9
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
const safe = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
11
|
+
const hash = /^[a-f0-9]{64}$/;
|
|
12
|
+
export function entry(p) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.lstatSync(p);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
if (e.code === "ENOENT") return;
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function safePath(p) {
|
|
21
|
+
if (!path.isAbsolute(p) || path.normalize(p) !== p) throw Error("Unsafe retained path");
|
|
22
|
+
for (let a = p; a !== path.dirname(a); a = path.dirname(a)) if (entry(a)?.isSymbolicLink()) throw Error("Linked retained path");
|
|
23
|
+
}
|
|
24
|
+
function owned(p, directory = false) {
|
|
25
|
+
safePath(p);
|
|
26
|
+
const s = entry(p);
|
|
27
|
+
if (!s || s.uid !== process.getuid?.() || s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1))
|
|
28
|
+
throw Error("Unsafe retained entry");
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
function privateRoot(p) {
|
|
32
|
+
const s = owned(p, true);
|
|
33
|
+
if (s.mode & 0o077) throw Error("Retained root must be private");
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
export function readJSON(p, limit = 131072) {
|
|
37
|
+
const s = owned(p);
|
|
38
|
+
if (s.size > limit) throw Error("Retained metadata limit");
|
|
39
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
function sync(p) {
|
|
42
|
+
const fd = fs.openSync(p, "r");
|
|
43
|
+
try {
|
|
44
|
+
fs.fsyncSync(fd);
|
|
45
|
+
} finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function atomicJSON(p, value) {
|
|
50
|
+
safePath(p);
|
|
51
|
+
const temp = p + "." + randomUUID() + ".tmp";
|
|
52
|
+
try {
|
|
53
|
+
const fd = fs.openSync(temp, "wx", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
fs.writeFileSync(fd, JSON.stringify(value));
|
|
56
|
+
fs.fsyncSync(fd);
|
|
57
|
+
} finally {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
fs.renameSync(temp, p);
|
|
61
|
+
sync(path.dirname(p));
|
|
62
|
+
} finally {
|
|
63
|
+
if (entry(temp)) fs.unlinkSync(temp);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function available(root) {
|
|
67
|
+
privateRoot(root);
|
|
68
|
+
if (entry(path.join(root, RESTORE_FILE)) && readJSON(path.join(root, RESTORE_FILE)).state !== "complete")
|
|
69
|
+
throw Error("Retained restore is incomplete");
|
|
70
|
+
}
|
|
71
|
+
function lock(file, exclusive) {
|
|
72
|
+
safePath(file);
|
|
73
|
+
if (entry(file)) owned(file);
|
|
74
|
+
const db = new DatabaseSync(file);
|
|
75
|
+
try {
|
|
76
|
+
fs.chmodSync(file, 0o600);
|
|
77
|
+
db.exec("PRAGMA busy_timeout=0; PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS lease(id INTEGER PRIMARY KEY);");
|
|
78
|
+
db.exec(exclusive ? "BEGIN EXCLUSIVE" : "BEGIN; SELECT count(*) FROM lease;");
|
|
79
|
+
} catch (e) {
|
|
80
|
+
db.close();
|
|
81
|
+
throw Error("Retained storage is owned or unavailable", { cause: e });
|
|
82
|
+
}
|
|
83
|
+
return () => {
|
|
84
|
+
try {
|
|
85
|
+
db.exec("ROLLBACK");
|
|
86
|
+
} finally {
|
|
87
|
+
db.close();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function rootLease(root, exclusive = false, restoring = false) {
|
|
92
|
+
if (restoring) privateRoot(root);
|
|
93
|
+
else available(root);
|
|
94
|
+
return lock(path.join(root, "retained-root-lease.sqlite"), exclusive);
|
|
95
|
+
}
|
|
96
|
+
export function maintenanceLease(directory) {
|
|
97
|
+
const releaseRoot = rootLease(path.dirname(directory));
|
|
98
|
+
try {
|
|
99
|
+
privateRoot(directory);
|
|
100
|
+
const releaseStore = lock(path.join(directory, "store-lease.sqlite"), true);
|
|
101
|
+
return () => {
|
|
102
|
+
try {
|
|
103
|
+
releaseStore();
|
|
104
|
+
} finally {
|
|
105
|
+
releaseRoot();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
} catch (e) {
|
|
109
|
+
releaseRoot();
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function stable(v) {
|
|
114
|
+
return JSON.stringify(v, (_, x) =>
|
|
115
|
+
x && typeof x === "object" && !Array.isArray(x)
|
|
116
|
+
? Object.fromEntries(
|
|
117
|
+
Object.keys(x)
|
|
118
|
+
.sort()
|
|
119
|
+
.map((k) => [k, x[k]]),
|
|
120
|
+
)
|
|
121
|
+
: x,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const activeLeases = (globalThis[Symbol.for("uns-kit.retained-store.leases.v1")] ??= new Map());
|
|
125
|
+
export function runtimeLease(binding) {
|
|
126
|
+
if (activeLeases.has(binding.directory)) throw Error("Retained store already owned by this process");
|
|
127
|
+
const release = maintenanceLease(binding.directory);
|
|
128
|
+
try {
|
|
129
|
+
if (entry(path.join(binding.directory, OPERATION))) throw Error("Retained operation is incomplete");
|
|
130
|
+
if (entry(path.join(binding.directory, "store-returned.json"))) throw Error("Retained store is retired");
|
|
131
|
+
const current = readJSON(path.join(binding.directory, "store.json"));
|
|
132
|
+
declaration(readJSON(path.join(binding.directory, DECLARATION)), binding.format);
|
|
133
|
+
if (stable(current) !== stable(binding)) throw Error("Retained owner changed");
|
|
134
|
+
let closed = false;
|
|
135
|
+
const held = {
|
|
136
|
+
close() {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
closed = true;
|
|
139
|
+
activeLeases.delete(binding.directory);
|
|
140
|
+
release();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
activeLeases.set(binding.directory, held);
|
|
144
|
+
return held;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
release();
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function sha(file) {
|
|
151
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
152
|
+
const h = createHash("sha256"),
|
|
153
|
+
buffer = Buffer.alloc(65536);
|
|
154
|
+
try {
|
|
155
|
+
let n;
|
|
156
|
+
while ((n = fs.readSync(fd, buffer)) > 0) h.update(buffer.subarray(0, n));
|
|
157
|
+
return h.digest("hex");
|
|
158
|
+
} finally {
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function relative(p) {
|
|
163
|
+
return (
|
|
164
|
+
typeof p === "string" &&
|
|
165
|
+
p.length <= 4096 &&
|
|
166
|
+
!path.isAbsolute(p) &&
|
|
167
|
+
p.split("/").every((x) => x !== "." && x !== ".." && /^[a-zA-Z0-9._-]{1,255}$/.test(x))
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
function binding(value, root, id) {
|
|
171
|
+
if (
|
|
172
|
+
!value ||
|
|
173
|
+
value.protocol !== 1 ||
|
|
174
|
+
value.storeId !== id ||
|
|
175
|
+
!uuid.test(id) ||
|
|
176
|
+
!uuid.test(value.generation) ||
|
|
177
|
+
!uuid.test(value.operationId) ||
|
|
178
|
+
value.directory !== path.join(root, id) ||
|
|
179
|
+
!safe.test(value.format) ||
|
|
180
|
+
!hash.test(value.configurationDigest) ||
|
|
181
|
+
value.maintenance !== true ||
|
|
182
|
+
!value.owner ||
|
|
183
|
+
!["rttNode", "version", "instanceId"].every((k) => safe.test(value.owner[k]))
|
|
184
|
+
)
|
|
185
|
+
throw Error("Invalid retained binding");
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
function declaration(value, format) {
|
|
189
|
+
if (
|
|
190
|
+
!value ||
|
|
191
|
+
value.protocol !== 1 ||
|
|
192
|
+
value.storageProtocol !== 1 ||
|
|
193
|
+
value.format !== format ||
|
|
194
|
+
!Array.isArray(value.resources) ||
|
|
195
|
+
value.resources.length < 1 ||
|
|
196
|
+
value.resources.length > 16
|
|
197
|
+
)
|
|
198
|
+
throw Error("Invalid retained resources");
|
|
199
|
+
const ids = new Set();
|
|
200
|
+
for (const r of value.resources) {
|
|
201
|
+
if (
|
|
202
|
+
!safe.test(r.id) ||
|
|
203
|
+
["store.json", DECLARATION, OPERATION, "store-lease.sqlite", "store-returned.json"].includes(r.id) ||
|
|
204
|
+
ids.has(r.id) ||
|
|
205
|
+
!relative(r.path) ||
|
|
206
|
+
!["file", "directory"].includes(r.kind) ||
|
|
207
|
+
typeof r.required !== "boolean" ||
|
|
208
|
+
!["payload", "metadata"].includes(r.backup ?? "payload")
|
|
209
|
+
)
|
|
210
|
+
throw Error("Invalid retained resource");
|
|
211
|
+
ids.add(r.id);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function checkLimit(limits) {
|
|
216
|
+
if (
|
|
217
|
+
limits.schemaVersion !== 1 ||
|
|
218
|
+
limits.maximumStores !== 32 ||
|
|
219
|
+
limits.maximumEntries !== 100000 ||
|
|
220
|
+
limits.maximumBytes !== 68719476736 ||
|
|
221
|
+
limits.maximumManifestBytes !== 33554432 ||
|
|
222
|
+
limits.maximumFileBytes !== 34359738368 ||
|
|
223
|
+
limits.maximumDepth !== 32
|
|
224
|
+
)
|
|
225
|
+
throw Error("Unsupported retained bounds");
|
|
226
|
+
}
|
|
227
|
+
export function captureRetained(root, destination, mode, limits) {
|
|
228
|
+
checkLimit(limits);
|
|
229
|
+
if (!["include", "exclude"].includes(mode)) throw Error("Invalid retained policy");
|
|
230
|
+
privateRoot(destination);
|
|
231
|
+
const release = rootLease(root, true),
|
|
232
|
+
releases = [];
|
|
233
|
+
try {
|
|
234
|
+
const manifest = { schemaVersion: 1, kind: "uns-retained-store-snapshot", sourceRoot: root, mode, stores: [], entries: [] };
|
|
235
|
+
let total = 0;
|
|
236
|
+
function copy(source, rel, depth = 0) {
|
|
237
|
+
if (depth > limits.maximumDepth || !relative(rel)) throw Error("Retained depth/path limit");
|
|
238
|
+
const s = owned(source, safeDirectory(source));
|
|
239
|
+
if (manifest.entries.length >= limits.maximumEntries) throw Error("Retained entry limit");
|
|
240
|
+
const out = path.join(destination, rel);
|
|
241
|
+
if (s.isDirectory()) {
|
|
242
|
+
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
243
|
+
manifest.entries.push({ path: rel, type: "directory" });
|
|
244
|
+
for (const n of fs.readdirSync(source).sort()) copy(path.join(source, n), rel + "/" + n, depth + 1);
|
|
245
|
+
} else {
|
|
246
|
+
total += s.size;
|
|
247
|
+
if (total > limits.maximumBytes || s.size > limits.maximumFileBytes) throw Error("Retained byte limit");
|
|
248
|
+
fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
|
|
249
|
+
const before = sha(source);
|
|
250
|
+
fs.copyFileSync(source, out, fs.constants.COPYFILE_EXCL);
|
|
251
|
+
fs.chmodSync(out, 0o600);
|
|
252
|
+
const after = owned(source),
|
|
253
|
+
result = sha(out);
|
|
254
|
+
if (before !== result || before !== sha(source) || after.ino !== s.ino || after.size !== s.size || after.mtimeMs !== s.mtimeMs)
|
|
255
|
+
throw Error("Retained source changed");
|
|
256
|
+
manifest.entries.push({ path: rel, type: "file", size: s.size, sha256: result });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const names = fs.readdirSync(root).sort();
|
|
260
|
+
for (const id of names) {
|
|
261
|
+
if (["retained-root-lease.sqlite", RESTORE_FILE].includes(id)) continue;
|
|
262
|
+
if (!uuid.test(id) || manifest.stores.length >= limits.maximumStores) throw Error("Unexpected retained root entry");
|
|
263
|
+
const dir = path.join(root, id);
|
|
264
|
+
privateRoot(dir);
|
|
265
|
+
const unlock = lock(path.join(dir, "store-lease.sqlite"), true);
|
|
266
|
+
releases.push(unlock);
|
|
267
|
+
if (entry(path.join(dir, OPERATION))) throw Error("Retained operation is incomplete");
|
|
268
|
+
const b = binding(readJSON(path.join(dir, "store.json")), root, id);
|
|
269
|
+
const d = declaration(readJSON(path.join(dir, DECLARATION)), b.format);
|
|
270
|
+
if (entry(path.join(dir, "store-returned.json"))) {
|
|
271
|
+
if (fs.readdirSync(dir).some((n) => !["store.json", DECLARATION, "store-lease.sqlite", "store-returned.json"].includes(n)))
|
|
272
|
+
throw Error("Undeclared returned-store entry");
|
|
273
|
+
const r = readJSON(path.join(dir, "store-returned.json"));
|
|
274
|
+
if (r.operationId !== b.operationId || r.generation !== b.generation || d.resources.some((x) => entry(path.join(dir, x.id))))
|
|
275
|
+
throw Error("Uncertain returned store");
|
|
276
|
+
fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
|
|
277
|
+
manifest.entries.push({ path: id, type: "directory" });
|
|
278
|
+
for (const name of ["store.json", DECLARATION, "store-returned.json"]) copy(path.join(dir, name), id + "/" + name);
|
|
279
|
+
manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources: [], retired: true });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const allowed = new Set(["store.json", DECLARATION, "store-lease.sqlite", ...d.resources.map((x) => x.id)]);
|
|
283
|
+
if (fs.readdirSync(dir).some((n) => !allowed.has(n))) throw Error("Undeclared retained entry");
|
|
284
|
+
fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
|
|
285
|
+
manifest.entries.push({ path: id, type: "directory" });
|
|
286
|
+
copy(path.join(dir, "store.json"), id + "/store.json");
|
|
287
|
+
copy(path.join(dir, DECLARATION), id + "/" + DECLARATION);
|
|
288
|
+
const resources = [];
|
|
289
|
+
for (const r of d.resources) {
|
|
290
|
+
const p = path.join(dir, r.id),
|
|
291
|
+
s = entry(p);
|
|
292
|
+
if (!s) {
|
|
293
|
+
if (r.required) throw Error("Required retained resource missing");
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if ((r.kind === "directory") !== s.isDirectory()) throw Error("Retained resource kind mismatch");
|
|
297
|
+
const included = mode === "include" || r.backup === "metadata";
|
|
298
|
+
resources.push({ id: r.id, backup: r.backup ?? "payload", included });
|
|
299
|
+
if (included) copy(p, id + "/" + r.id);
|
|
300
|
+
}
|
|
301
|
+
manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources });
|
|
302
|
+
}
|
|
303
|
+
const raw = JSON.stringify(manifest);
|
|
304
|
+
if (Buffer.byteLength(raw) > limits.maximumManifestBytes) throw Error("Retained manifest limit");
|
|
305
|
+
atomicJSON(path.join(destination, "manifest.json"), manifest);
|
|
306
|
+
return {
|
|
307
|
+
state: manifest.stores.length ? (mode === "include" ? "included" : "excluded") : "empty",
|
|
308
|
+
stores: manifest.stores.length,
|
|
309
|
+
files: manifest.entries.filter((e) => e.type === "file").length,
|
|
310
|
+
payloadBytes: total,
|
|
311
|
+
};
|
|
312
|
+
} finally {
|
|
313
|
+
for (const f of releases.reverse()) f();
|
|
314
|
+
release();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function safeDirectory(p) {
|
|
318
|
+
return entry(p)?.isDirectory() === true;
|
|
319
|
+
}
|
|
320
|
+
function exactTree(directory, expected) {
|
|
321
|
+
const actual = new Set();
|
|
322
|
+
function walk(dir, prefix = "") {
|
|
323
|
+
for (const n of fs.readdirSync(dir)) {
|
|
324
|
+
const rel = prefix ? prefix + "/" + n : n,
|
|
325
|
+
p = path.join(dir, n);
|
|
326
|
+
const st = owned(p, safeDirectory(p));
|
|
327
|
+
actual.add(rel);
|
|
328
|
+
if (st.isDirectory()) walk(p, rel);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
walk(directory);
|
|
332
|
+
if (actual.size !== expected.size || [...actual].some((p) => !expected.has(p))) throw Error("Undeclared snapshot or staging entry");
|
|
333
|
+
}
|
|
334
|
+
function verifySnapshot(source, limits) {
|
|
335
|
+
const m = readJSON(path.join(source, "manifest.json"), limits.maximumManifestBytes);
|
|
336
|
+
if (
|
|
337
|
+
m.schemaVersion !== 1 ||
|
|
338
|
+
m.kind !== "uns-retained-store-snapshot" ||
|
|
339
|
+
!["include", "exclude"].includes(m.mode) ||
|
|
340
|
+
!Array.isArray(m.stores) ||
|
|
341
|
+
m.stores.length > limits.maximumStores ||
|
|
342
|
+
!Array.isArray(m.entries) ||
|
|
343
|
+
m.entries.length > limits.maximumEntries
|
|
344
|
+
)
|
|
345
|
+
throw Error("Invalid retained snapshot");
|
|
346
|
+
let total = 0;
|
|
347
|
+
const seen = new Set();
|
|
348
|
+
for (const e of m.entries) {
|
|
349
|
+
if (!relative(e.path) || !uuid.test(e.path.split("/")[0]) || seen.has(e.path) || !["file", "directory"].includes(e.type))
|
|
350
|
+
throw Error("Invalid retained snapshot path");
|
|
351
|
+
seen.add(e.path);
|
|
352
|
+
const p = path.join(source, e.path),
|
|
353
|
+
s = owned(p, e.type === "directory");
|
|
354
|
+
if (e.type === "file") {
|
|
355
|
+
total += s.size;
|
|
356
|
+
if (!hash.test(e.sha256) || s.size !== e.size || s.size > limits.maximumFileBytes || total > limits.maximumBytes || sha(p) !== e.sha256)
|
|
357
|
+
throw Error("Retained snapshot changed");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const allowed = new Set(["manifest.json", ...m.entries.map((e) => e.path)]);
|
|
361
|
+
exactTree(source, allowed);
|
|
362
|
+
const stores = new Map(m.stores.map((v) => [v.storeId, v]));
|
|
363
|
+
if (stores.size !== m.stores.length || m.entries.some((e) => !stores.has(e.path.split("/")[0]))) throw Error("Undeclared snapshot store");
|
|
364
|
+
for (const st of m.stores) {
|
|
365
|
+
const b = binding(readJSON(path.join(source, st.storeId, "store.json")), m.sourceRoot, st.storeId);
|
|
366
|
+
declaration(readJSON(path.join(source, st.storeId, DECLARATION)), b.format);
|
|
367
|
+
if (st.retired) {
|
|
368
|
+
const r = readJSON(path.join(source, st.storeId, "store-returned.json"));
|
|
369
|
+
if (
|
|
370
|
+
r.operationId !== b.operationId ||
|
|
371
|
+
r.generation !== b.generation ||
|
|
372
|
+
m.entries.some(
|
|
373
|
+
(e) =>
|
|
374
|
+
e.path.startsWith(st.storeId + "/") && !["store.json", DECLARATION, "store-returned.json"].includes(e.path.slice(st.storeId.length + 1)),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
throw Error("Invalid retired-store receipt");
|
|
378
|
+
}
|
|
379
|
+
if (st.generation !== b.generation || stable(st.binding) !== stable(b)) throw Error("Snapshot owner differs");
|
|
380
|
+
}
|
|
381
|
+
return m;
|
|
382
|
+
}
|
|
383
|
+
export function restoreRetained(source, root, backupDigest, limits, afterPublish) {
|
|
384
|
+
checkLimit(limits);
|
|
385
|
+
if (!hash.test(backupDigest)) throw Error("Invalid reviewed backup digest");
|
|
386
|
+
const m = verifySnapshot(source, limits);
|
|
387
|
+
if (m.mode !== "include") throw Error("Payloads excluded; reuse preserved storage");
|
|
388
|
+
if (m.sourceRoot !== root) throw Error("Retained restore requires the same logical Runtime root");
|
|
389
|
+
const release = rootLease(root, true, true);
|
|
390
|
+
try {
|
|
391
|
+
const markerFile = path.join(root, RESTORE_FILE),
|
|
392
|
+
manifestDigest = sha(path.join(source, "manifest.json"));
|
|
393
|
+
let marker = entry(markerFile) ? readJSON(markerFile) : null;
|
|
394
|
+
if (marker && (marker.backupDigest !== backupDigest || marker.manifestDigest !== manifestDigest))
|
|
395
|
+
throw Error("Another retained restore owns this target");
|
|
396
|
+
const ids = m.stores.map((s) => s.storeId);
|
|
397
|
+
if (new Set(ids).size !== ids.length || ids.some((id) => !uuid.test(id))) throw Error("Invalid snapshot store identities");
|
|
398
|
+
if (marker?.state === "complete") {
|
|
399
|
+
for (const id of ids) binding(readJSON(path.join(root, id, "store.json")), root, id);
|
|
400
|
+
return { state: "complete", stores: ids.length };
|
|
401
|
+
}
|
|
402
|
+
if (!marker) {
|
|
403
|
+
if (fs.readdirSync(root).some((n) => n !== "retained-root-lease.sqlite")) throw Error("Retained target is not empty");
|
|
404
|
+
marker = { protocol: 1, state: "applying", backupDigest, manifestDigest };
|
|
405
|
+
atomicJSON(markerFile, marker);
|
|
406
|
+
}
|
|
407
|
+
const stage = path.join(root, "retained-restore-stage");
|
|
408
|
+
if (!entry(stage)) fs.mkdirSync(stage, { mode: 0o700 });
|
|
409
|
+
privateRoot(stage);
|
|
410
|
+
const allowed = new Set(["retained-root-lease.sqlite", RESTORE_FILE, "retained-restore-stage", ...ids]);
|
|
411
|
+
if (fs.readdirSync(root).some((n) => !allowed.has(n))) throw Error("Unexpected restore target entry");
|
|
412
|
+
for (const e of m.entries) {
|
|
413
|
+
const id = e.path.split("/")[0],
|
|
414
|
+
published = entry(path.join(root, id)),
|
|
415
|
+
out = path.join(published ? root : stage, e.path),
|
|
416
|
+
src = path.join(source, e.path);
|
|
417
|
+
if (e.type === "directory") {
|
|
418
|
+
if (!entry(out)) fs.mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
419
|
+
privateRoot(out);
|
|
420
|
+
} else {
|
|
421
|
+
const current = entry(out);
|
|
422
|
+
if (current) {
|
|
423
|
+
const stat = owned(out);
|
|
424
|
+
if (stat.size === e.size && sha(out) === e.sha256) continue;
|
|
425
|
+
if (published) throw Error("Published retained bytes differ; preserve them for review");
|
|
426
|
+
}
|
|
427
|
+
if (published) throw Error("Published retained store is incomplete");
|
|
428
|
+
fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
|
|
429
|
+
const part = path.join(stage, "retained-file-" + createHash("sha256").update(e.path).digest("hex") + ".part");
|
|
430
|
+
if (entry(part)) owned(part);
|
|
431
|
+
fs.copyFileSync(src, part);
|
|
432
|
+
fs.chmodSync(part, 0o600);
|
|
433
|
+
const fd = fs.openSync(part, "r");
|
|
434
|
+
try {
|
|
435
|
+
fs.fsyncSync(fd);
|
|
436
|
+
} finally {
|
|
437
|
+
fs.closeSync(fd);
|
|
438
|
+
}
|
|
439
|
+
if (sha(part) !== e.sha256) throw Error("Restored retained bytes differ");
|
|
440
|
+
fs.renameSync(part, out);
|
|
441
|
+
sync(path.dirname(out));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const knownParts = new Set(
|
|
445
|
+
m.entries.filter((e) => e.type === "file").map((e) => "retained-file-" + createHash("sha256").update(e.path).digest("hex") + ".part"),
|
|
446
|
+
);
|
|
447
|
+
for (const name of fs.readdirSync(stage)) {
|
|
448
|
+
if (knownParts.has(name)) {
|
|
449
|
+
owned(path.join(stage, name));
|
|
450
|
+
fs.unlinkSync(path.join(stage, name));
|
|
451
|
+
} else if (!ids.includes(name)) throw Error("Unexpected restore staging entry");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const id of ids) {
|
|
455
|
+
const dir = entry(path.join(root, id)) ? path.join(root, id) : path.join(stage, id),
|
|
456
|
+
prefix = id + "/";
|
|
457
|
+
exactTree(dir, new Set(m.entries.filter((e) => e.path.startsWith(prefix)).map((e) => e.path.slice(prefix.length))));
|
|
458
|
+
}
|
|
459
|
+
for (const e of [...m.entries].filter((e) => e.type === "directory").sort((a, b) => b.path.length - a.path.length)) {
|
|
460
|
+
const base = entry(path.join(root, e.path.split("/")[0])) ? root : stage;
|
|
461
|
+
sync(path.join(base, e.path));
|
|
462
|
+
}
|
|
463
|
+
for (const id of ids) {
|
|
464
|
+
const to = path.join(root, id);
|
|
465
|
+
if (!entry(to)) {
|
|
466
|
+
binding(readJSON(path.join(stage, id, "store.json")), root, id);
|
|
467
|
+
fs.renameSync(path.join(stage, id), to);
|
|
468
|
+
sync(stage);
|
|
469
|
+
sync(root);
|
|
470
|
+
afterPublish?.(id);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (fs.readdirSync(stage).length) throw Error("Unknown restore staging files");
|
|
474
|
+
fs.rmdirSync(stage);
|
|
475
|
+
atomicJSON(markerFile, { ...marker, state: "complete" });
|
|
476
|
+
return { state: "complete", stores: ids.length };
|
|
477
|
+
} finally {
|
|
478
|
+
release();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
export function retainedStorageMain() {
|
|
482
|
+
const [action, root, out, raw, modeOrDigest] = process.argv.slice(2),
|
|
483
|
+
limits = JSON.parse(raw);
|
|
484
|
+
const result =
|
|
485
|
+
action === "capture"
|
|
486
|
+
? captureRetained(root, out, modeOrDigest, limits)
|
|
487
|
+
: action === "restore"
|
|
488
|
+
? restoreRetained(root, out, modeOrDigest, limits)
|
|
489
|
+
: (() => {
|
|
490
|
+
throw Error("Unknown retained operation");
|
|
491
|
+
})();
|
|
492
|
+
process.stdout.write(JSON.stringify(result));
|
|
493
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const RETAINED_STORE_MANIFEST = "retained-store.manifest.json";
|
|
4
|
+
export declare const RETAINED_STORE_BINDING = "retained-store-binding.json";
|
|
5
|
+
export declare const RETAINED_STORE_GUARD = "retained-store-guard.json";
|
|
6
|
+
export declare const retainedStoreManifestSchema: z.ZodObject<{
|
|
7
|
+
protocol: z.ZodLiteral<1>;
|
|
8
|
+
storageProtocol: z.ZodOptional<z.ZodLiteral<1>>;
|
|
9
|
+
format: z.ZodString;
|
|
10
|
+
resources: z.ZodArray<z.ZodObject<{
|
|
11
|
+
id: z.ZodString;
|
|
12
|
+
path: z.ZodString;
|
|
13
|
+
kind: z.ZodEnum<{
|
|
14
|
+
file: "file";
|
|
15
|
+
directory: "directory";
|
|
16
|
+
}>;
|
|
17
|
+
required: z.ZodBoolean;
|
|
18
|
+
backup: z.ZodOptional<z.ZodEnum<{
|
|
19
|
+
payload: "payload";
|
|
20
|
+
metadata: "metadata";
|
|
21
|
+
}>>;
|
|
22
|
+
}, z.core.$strict>>;
|
|
23
|
+
legacyVersions: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
24
|
+
}, z.core.$strict>;
|
|
25
|
+
export type RetainedStoreManifest = z.infer<typeof retainedStoreManifestSchema>;
|
|
26
|
+
export type StoreOwner = {
|
|
27
|
+
rttNode: string;
|
|
28
|
+
version: string;
|
|
29
|
+
instanceId: string;
|
|
30
|
+
};
|
|
31
|
+
export type StoreBinding = {
|
|
32
|
+
protocol: 1;
|
|
33
|
+
storeId: string;
|
|
34
|
+
generation: string;
|
|
35
|
+
operationId: string;
|
|
36
|
+
directory: string;
|
|
37
|
+
format: string;
|
|
38
|
+
configurationDigest: string;
|
|
39
|
+
owner: StoreOwner;
|
|
40
|
+
maintenance: true;
|
|
41
|
+
};
|
|
42
|
+
export type ResourceMove = {
|
|
43
|
+
id: string;
|
|
44
|
+
relative: string;
|
|
45
|
+
kind: "file" | "directory";
|
|
46
|
+
device: number;
|
|
47
|
+
inode: number;
|
|
48
|
+
};
|
|
49
|
+
export type StoreMovePlan = {
|
|
50
|
+
storeId: string;
|
|
51
|
+
generation: string;
|
|
52
|
+
format: string;
|
|
53
|
+
source: string;
|
|
54
|
+
directory: string;
|
|
55
|
+
slots: RetainedStoreManifest["resources"];
|
|
56
|
+
moves: ResourceMove[];
|
|
57
|
+
};
|
|
58
|
+
export declare function retainedStoreDigest(value: unknown): string;
|
|
59
|
+
export declare function retainedStoreEntry(file: string): fs.Stats | undefined;
|
|
60
|
+
export declare function assertRetainedStorePath(file: string): void;
|
|
61
|
+
export declare function readRetainedStoreJson(file: string): unknown;
|
|
62
|
+
export declare function syncRetainedDirectory(directory: string): void;
|
|
63
|
+
export declare function writeRetainedStoreJson(file: string, value: unknown): void;
|
|
64
|
+
export declare function readRetainedStoreManifest(packageDirectory: string): RetainedStoreManifest;
|
|
65
|
+
export declare function prepareRetainedStoreMove(source: string, root: string, storeId: string, manifest: RetainedStoreManifest): StoreMovePlan;
|
|
66
|
+
/** Caller durably records the complete plan and holds its resource reservation first.
|
|
67
|
+
* Each rename is atomic; the multi-resource operation is deliberately resumable. */
|
|
68
|
+
export declare function applyRetainedStoreMove(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void): void;
|
|
69
|
+
export declare function writeRetainedStoreBinding(instanceDirectory: string, binding: StoreBinding): void;
|
|
70
|
+
export declare function loadRetainedStoreBinding(instanceDirectory: string, operationId?: string): StoreBinding | undefined;
|
|
71
|
+
/** No application payload/schema inspection is performed by this helper. */
|
|
72
|
+
export declare function resolveRetainedStoreResources(instanceDirectory: string, manifest: RetainedStoreManifest, expected?: Partial<StoreOwner>): {
|
|
73
|
+
binding?: StoreBinding;
|
|
74
|
+
resources: Record<string, string>;
|
|
75
|
+
};
|
|
76
|
+
export declare const retainedStoreSelectionSchema: z.ZodObject<{
|
|
77
|
+
requestId: z.ZodUUID;
|
|
78
|
+
rttNode: z.ZodString;
|
|
79
|
+
sourceVersion: z.ZodString;
|
|
80
|
+
sourceInstanceId: z.ZodString;
|
|
81
|
+
targetVersion: z.ZodString;
|
|
82
|
+
targetInstanceId: z.ZodString;
|
|
83
|
+
}, z.core.$strict>;
|
|
84
|
+
export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
85
|
+
action: z.ZodLiteral<"review">;
|
|
86
|
+
selection: z.ZodObject<{
|
|
87
|
+
requestId: z.ZodUUID;
|
|
88
|
+
rttNode: z.ZodString;
|
|
89
|
+
sourceVersion: z.ZodString;
|
|
90
|
+
sourceInstanceId: z.ZodString;
|
|
91
|
+
targetVersion: z.ZodString;
|
|
92
|
+
targetInstanceId: z.ZodString;
|
|
93
|
+
}, z.core.$strict>;
|
|
94
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
95
|
+
action: z.ZodLiteral<"get">;
|
|
96
|
+
id: z.ZodUUID;
|
|
97
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
98
|
+
action: z.ZodEnum<{
|
|
99
|
+
adopt: "adopt";
|
|
100
|
+
return: "return";
|
|
101
|
+
}>;
|
|
102
|
+
id: z.ZodUUID;
|
|
103
|
+
reviewedChecksum: z.ZodString;
|
|
104
|
+
}, z.core.$strict>], "action">;
|
|
105
|
+
export type RetainedStoreRequest = z.infer<typeof retainedStoreRequestSchema>;
|
|
106
|
+
/** Capture mutable control files from the current stopped owner before persisting return intent. */
|
|
107
|
+
export declare function prepareRetainedStoreReturn(plan: StoreMovePlan): StoreMovePlan;
|
|
108
|
+
export declare function retainedStoreRoot(defaultBase: string, configured?: string): string;
|
|
109
|
+
export declare function openRetainedStoreLease(binding: StoreBinding): {
|
|
110
|
+
close: () => void;
|
|
111
|
+
};
|
|
112
|
+
export declare function openRetainedStoreMaintenance(directory: string): () => void;
|
|
113
|
+
export declare const retainedStoreBackupSchema: z.ZodObject<{
|
|
114
|
+
schemaVersion: z.ZodLiteral<1>;
|
|
115
|
+
state: z.ZodEnum<{
|
|
116
|
+
included: "included";
|
|
117
|
+
empty: "empty";
|
|
118
|
+
excluded: "excluded";
|
|
119
|
+
}>;
|
|
120
|
+
rootSha256: z.ZodString;
|
|
121
|
+
stores: z.ZodNumber;
|
|
122
|
+
files: z.ZodNumber;
|
|
123
|
+
bytes: z.ZodNumber;
|
|
124
|
+
sha256: z.ZodString;
|
|
125
|
+
}, z.core.$strict>;
|
|
126
|
+
//# sourceMappingURL=retained-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retained-store.d.ts","sourceRoot":"","sources":["../../src/runtime/retained-store.ts"],"names":[],"mappings":"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"}
|