@happyvertical/smrt-template-sveltekit 0.43.10 → 0.44.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/AGENTS.md +31 -6
- package/README.md +26 -10
- package/index.js +53 -2
- package/package.json +19 -5
- package/template/.dockerignore +12 -0
- package/template/.env.cloud.example +9 -0
- package/template/.env.example +15 -3
- package/template/.env.self-hosted.example +11 -0
- package/template/AGENTS.md +32 -0
- package/template/Dockerfile +23 -0
- package/template/INSTALL_PROMPT.md +11 -0
- package/template/README.md +74 -32
- package/template/compose.yaml +56 -0
- package/template/gitignore.template +45 -0
- package/template/package.json +30 -15
- package/template/scripts/smrt-app.mjs +884 -0
- package/template/scripts/smrt-operation-lock.mjs +102 -0
- package/template/scripts/smrt-portability-assets.mjs +707 -0
- package/template/scripts/smrt-portability.mjs +470 -0
- package/template/scripts/smrt-prepare-migration.mjs +93 -0
- package/template/scripts/smrt-process.mjs +82 -0
- package/template/scripts/smrt-provider-readiness.mjs +33 -0
- package/template/scripts/smrt-runtime-identity.mjs +238 -0
- package/template/scripts/smrt-vite.mjs +45 -0
- package/template/scripts/smrt-web.mjs +10 -0
- package/template/scripts/smrt-worker.mjs +86 -0
- package/template/scripts/smrt-writer-lease.mjs +199 -0
- package/template/smrt.config.ts +34 -2
- package/template/src/hooks.server.ts +4 -1
- package/template/src/lib/RuntimeDiagnosticsWebMcp.svelte +11 -0
- package/template/src/lib/runtime-diagnostics-webmcp.ts +108 -0
- package/template/src/lib/server/application-runtime.ts +262 -0
- package/template/src/lib/server/runtime-diagnostics.ts +114 -0
- package/template/src/lib/server/smrt.ts +4 -4
- package/template/src/routes/+layout.svelte +2 -0
- package/template/src/routes/api/_runtime/diagnostics/+server.ts +5 -0
- package/template/src/routes/api/_runtime/health/+server.ts +30 -0
- package/template/src/routes/setup/+page.server.ts +96 -0
- package/template/src/routes/setup/+page.svelte +31 -0
- package/template/svelte.config.js +1 -1
- package/template/tsconfig.json +1 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmdirSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import {
|
|
21
|
+
dirname,
|
|
22
|
+
isAbsolute,
|
|
23
|
+
join,
|
|
24
|
+
relative,
|
|
25
|
+
resolve,
|
|
26
|
+
sep,
|
|
27
|
+
} from 'node:path';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
|
|
30
|
+
export const ASSET_MANIFEST_SCHEMA_VERSION = 1;
|
|
31
|
+
export const MAX_ASSET_COUNT = 1_000;
|
|
32
|
+
export const MAX_ASSET_BYTES = 64 * 1024 * 1024;
|
|
33
|
+
export const MAX_TOTAL_ASSET_BYTES = 256 * 1024 * 1024;
|
|
34
|
+
export const MAX_BUNDLE_BYTES = 512 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
const ASSET_URI_PREFIX = 'smrt-bundle://asset/';
|
|
37
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
38
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
39
|
+
const MAX_BASE64_ASSET_LENGTH = Math.ceil(MAX_ASSET_BYTES / 3) * 4;
|
|
40
|
+
|
|
41
|
+
function fail(code) {
|
|
42
|
+
const error = new Error(`Asset portability validation failed (${code}).`);
|
|
43
|
+
error.code = code;
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isSanitizedFailure(error) {
|
|
48
|
+
return (
|
|
49
|
+
error &&
|
|
50
|
+
typeof error === 'object' &&
|
|
51
|
+
typeof error.code === 'string' &&
|
|
52
|
+
/^[a-z][a-z0-9-]*$/.test(error.code)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sha256(value) {
|
|
57
|
+
return createHash('sha256').update(value).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function digestBytes(value) {
|
|
61
|
+
return `sha256:${sha256(value)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isInside(parent, child) {
|
|
65
|
+
const nested = relative(parent, child);
|
|
66
|
+
return nested !== '' && nested !== '..' && !nested.startsWith(`..${sep}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertOutsideSource(sourceRoot, candidate, code) {
|
|
70
|
+
const source = realpathSync(sourceRoot);
|
|
71
|
+
const resolved = resolve(candidate);
|
|
72
|
+
if (resolved === source || isInside(source, resolved) || isInside(resolved, source)) {
|
|
73
|
+
throw fail(code);
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertRealDirectory(path, code) {
|
|
79
|
+
try {
|
|
80
|
+
const details = lstatSync(path);
|
|
81
|
+
if (details.isSymbolicLink() || !details.isDirectory()) throw fail(code);
|
|
82
|
+
if (realpathSync(path) !== resolve(path)) throw fail(code);
|
|
83
|
+
return resolve(path);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isSanitizedFailure(error)) throw error;
|
|
86
|
+
throw fail(code);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function assertAssetRoot({ sourceRoot, assetRoot, allowMissing = false }) {
|
|
91
|
+
if (typeof assetRoot !== 'string' || !isAbsolute(assetRoot)) {
|
|
92
|
+
throw fail('asset-root-required');
|
|
93
|
+
}
|
|
94
|
+
const root = assertOutsideSource(sourceRoot, assetRoot, 'unsafe-asset-root');
|
|
95
|
+
const parent = dirname(root);
|
|
96
|
+
assertRealDirectory(parent, 'unsafe-asset-root');
|
|
97
|
+
if (existsSync(root)) {
|
|
98
|
+
assertRealDirectory(root, 'unsafe-asset-root');
|
|
99
|
+
} else if (!allowMissing) {
|
|
100
|
+
throw fail('asset-root-missing');
|
|
101
|
+
}
|
|
102
|
+
return root;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizePayloadPath(value) {
|
|
106
|
+
if (
|
|
107
|
+
typeof value !== 'string' ||
|
|
108
|
+
value.length === 0 ||
|
|
109
|
+
value.length > 512 ||
|
|
110
|
+
value.includes('\\') ||
|
|
111
|
+
value.includes('\0') ||
|
|
112
|
+
value.startsWith('/') ||
|
|
113
|
+
value.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
114
|
+
) {
|
|
115
|
+
throw fail('unsafe-payload-path');
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeDestinationPath(value) {
|
|
121
|
+
const normalized = normalizePayloadPath(value);
|
|
122
|
+
if (!/^portable\/[a-f0-9]{64}$/.test(normalized)) {
|
|
123
|
+
throw fail('unsafe-destination-path');
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function portableUri(key) {
|
|
129
|
+
return `${ASSET_URI_PREFIX}${encodeURIComponent(key)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function payloadPathForKey(key) {
|
|
133
|
+
return `payloads/${sha256(key)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function destinationPathForKey(key) {
|
|
137
|
+
return `portable/${sha256(key)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readBoundedDescriptor(descriptor, expectedSize, changedCode) {
|
|
141
|
+
const bytes = Buffer.alloc(expectedSize);
|
|
142
|
+
let offset = 0;
|
|
143
|
+
while (offset < expectedSize) {
|
|
144
|
+
const count = readSync(
|
|
145
|
+
descriptor,
|
|
146
|
+
bytes,
|
|
147
|
+
offset,
|
|
148
|
+
expectedSize - offset,
|
|
149
|
+
null,
|
|
150
|
+
);
|
|
151
|
+
if (count === 0) throw fail(changedCode);
|
|
152
|
+
offset += count;
|
|
153
|
+
}
|
|
154
|
+
const overflowProbe = Buffer.allocUnsafe(1);
|
|
155
|
+
if (readSync(descriptor, overflowProbe, 0, 1, null) !== 0) {
|
|
156
|
+
throw fail(changedCode);
|
|
157
|
+
}
|
|
158
|
+
return bytes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readRegularFileUnchecked(path, approvedRoot, onOpened) {
|
|
162
|
+
const lexicalPath = resolve(path);
|
|
163
|
+
if (!isInside(approvedRoot, lexicalPath)) throw fail('asset-outside-root');
|
|
164
|
+
const before = lstatSync(lexicalPath);
|
|
165
|
+
if (before.isSymbolicLink() || !before.isFile()) throw fail('asset-not-regular');
|
|
166
|
+
if (before.size > MAX_ASSET_BYTES) throw fail('asset-too-large');
|
|
167
|
+
if (!isInside(approvedRoot, realpathSync(lexicalPath))) {
|
|
168
|
+
throw fail('asset-outside-root');
|
|
169
|
+
}
|
|
170
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
171
|
+
const descriptor = openSync(lexicalPath, constants.O_RDONLY | noFollow);
|
|
172
|
+
try {
|
|
173
|
+
const opened = fstatSync(descriptor);
|
|
174
|
+
if (!opened.isFile() || opened.size !== before.size) {
|
|
175
|
+
throw fail('asset-changed-during-read');
|
|
176
|
+
}
|
|
177
|
+
onOpened?.();
|
|
178
|
+
const openedPath = statSync(lexicalPath);
|
|
179
|
+
if (
|
|
180
|
+
!isInside(approvedRoot, realpathSync(lexicalPath)) ||
|
|
181
|
+
openedPath.dev !== opened.dev ||
|
|
182
|
+
openedPath.ino !== opened.ino
|
|
183
|
+
) {
|
|
184
|
+
throw fail('asset-changed-during-read');
|
|
185
|
+
}
|
|
186
|
+
const bytes = readBoundedDescriptor(
|
|
187
|
+
descriptor,
|
|
188
|
+
opened.size,
|
|
189
|
+
'asset-changed-during-read',
|
|
190
|
+
);
|
|
191
|
+
const after = fstatSync(descriptor);
|
|
192
|
+
const afterPath = statSync(lexicalPath);
|
|
193
|
+
if (
|
|
194
|
+
after.dev !== opened.dev ||
|
|
195
|
+
after.ino !== opened.ino ||
|
|
196
|
+
after.size !== opened.size ||
|
|
197
|
+
bytes.byteLength !== opened.size ||
|
|
198
|
+
!isInside(approvedRoot, realpathSync(lexicalPath)) ||
|
|
199
|
+
afterPath.dev !== opened.dev ||
|
|
200
|
+
afterPath.ino !== opened.ino
|
|
201
|
+
) {
|
|
202
|
+
throw fail('asset-changed-during-read');
|
|
203
|
+
}
|
|
204
|
+
return bytes;
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(descriptor);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readRegularFile(path, approvedRoot, onOpened) {
|
|
211
|
+
try {
|
|
212
|
+
return readRegularFileUnchecked(path, approvedRoot, onOpened);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (isSanitizedFailure(error)) throw error;
|
|
215
|
+
throw fail('asset-changed-during-read');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pathFromSourceUri(sourceUri) {
|
|
220
|
+
if (typeof sourceUri !== 'string' || !sourceUri.startsWith('file://')) {
|
|
221
|
+
throw fail('unsupported-asset-source');
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return fileURLToPath(sourceUri);
|
|
225
|
+
} catch {
|
|
226
|
+
throw fail('unsupported-asset-source');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function assetRows(tables) {
|
|
231
|
+
const table = tables.find((candidate) => candidate?.name === 'assets');
|
|
232
|
+
if (!table) return [];
|
|
233
|
+
if (!Array.isArray(table.rows) || !table.columns?.includes('id') || !table.columns?.includes('source_uri')) {
|
|
234
|
+
throw fail('invalid-asset-table');
|
|
235
|
+
}
|
|
236
|
+
return table.rows;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Replace provider paths with logical keys while collecting verified payloads. */
|
|
240
|
+
export function collectFilesystemAssets({
|
|
241
|
+
tables,
|
|
242
|
+
sourceRoot,
|
|
243
|
+
assetRoot,
|
|
244
|
+
onSourceOpened,
|
|
245
|
+
}) {
|
|
246
|
+
const rows = assetRows(tables);
|
|
247
|
+
if (rows.length === 0) {
|
|
248
|
+
return {
|
|
249
|
+
schemaVersion: ASSET_MANIFEST_SCHEMA_VERSION,
|
|
250
|
+
adapter: 'filesystem',
|
|
251
|
+
entries: [],
|
|
252
|
+
payloads: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (rows.length > MAX_ASSET_COUNT) throw fail('too-many-assets');
|
|
256
|
+
const root = assertAssetRoot({ sourceRoot, assetRoot });
|
|
257
|
+
const keys = new Set();
|
|
258
|
+
const payloadPaths = new Set();
|
|
259
|
+
const entries = [];
|
|
260
|
+
const payloads = [];
|
|
261
|
+
let totalBytes = 0;
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
if (!row?.source_uri) continue;
|
|
264
|
+
const key = String(row.id || '');
|
|
265
|
+
if (!key || key.length > 512 || keys.has(key)) throw fail('duplicate-asset-key');
|
|
266
|
+
keys.add(key);
|
|
267
|
+
const payloadPath = payloadPathForKey(key);
|
|
268
|
+
if (payloadPaths.has(payloadPath)) throw fail('duplicate-payload-path');
|
|
269
|
+
payloadPaths.add(payloadPath);
|
|
270
|
+
const bytes = readRegularFile(
|
|
271
|
+
pathFromSourceUri(row.source_uri),
|
|
272
|
+
root,
|
|
273
|
+
onSourceOpened,
|
|
274
|
+
);
|
|
275
|
+
totalBytes += bytes.byteLength;
|
|
276
|
+
if (totalBytes > MAX_TOTAL_ASSET_BYTES) throw fail('asset-total-too-large');
|
|
277
|
+
const contentDigest = digestBytes(bytes);
|
|
278
|
+
entries.push({
|
|
279
|
+
key,
|
|
280
|
+
byteLength: bytes.byteLength,
|
|
281
|
+
contentDigest,
|
|
282
|
+
payloadPath,
|
|
283
|
+
});
|
|
284
|
+
payloads.push({
|
|
285
|
+
path: payloadPath,
|
|
286
|
+
encoding: 'base64',
|
|
287
|
+
data: bytes.toString('base64'),
|
|
288
|
+
});
|
|
289
|
+
row.source_uri = portableUri(key);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
schemaVersion: ASSET_MANIFEST_SCHEMA_VERSION,
|
|
293
|
+
adapter: 'filesystem',
|
|
294
|
+
entries,
|
|
295
|
+
payloads,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function decodePayload(payload, expectedByteLength) {
|
|
300
|
+
if (
|
|
301
|
+
payload?.encoding !== 'base64' ||
|
|
302
|
+
typeof payload.data !== 'string' ||
|
|
303
|
+
payload.data.length > MAX_BASE64_ASSET_LENGTH ||
|
|
304
|
+
!BASE64_PATTERN.test(payload.data)
|
|
305
|
+
) {
|
|
306
|
+
throw fail('invalid-asset-payload');
|
|
307
|
+
}
|
|
308
|
+
const padding = payload.data.endsWith('==')
|
|
309
|
+
? 2
|
|
310
|
+
: payload.data.endsWith('=')
|
|
311
|
+
? 1
|
|
312
|
+
: 0;
|
|
313
|
+
const decodedLength = (payload.data.length / 4) * 3 - padding;
|
|
314
|
+
if (decodedLength !== expectedByteLength) throw fail('asset-size-mismatch');
|
|
315
|
+
const bytes = Buffer.from(payload.data, 'base64');
|
|
316
|
+
if (bytes.toString('base64') !== payload.data) {
|
|
317
|
+
throw fail('invalid-asset-payload');
|
|
318
|
+
}
|
|
319
|
+
return bytes;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Validate every manifest/payload relationship and hydrate destination URIs. */
|
|
323
|
+
export function verifyFilesystemAssets({ assetBundle, tables, sourceRoot, assetRoot }) {
|
|
324
|
+
if (
|
|
325
|
+
assetBundle?.schemaVersion !== ASSET_MANIFEST_SCHEMA_VERSION ||
|
|
326
|
+
assetBundle?.adapter !== 'filesystem' ||
|
|
327
|
+
!Array.isArray(assetBundle.entries) ||
|
|
328
|
+
!Array.isArray(assetBundle.payloads)
|
|
329
|
+
) {
|
|
330
|
+
throw fail('unsupported-asset-manifest');
|
|
331
|
+
}
|
|
332
|
+
if (assetBundle.entries.length > MAX_ASSET_COUNT) throw fail('too-many-assets');
|
|
333
|
+
if (assetBundle.entries.length !== assetBundle.payloads.length) {
|
|
334
|
+
throw fail('asset-payload-count-mismatch');
|
|
335
|
+
}
|
|
336
|
+
const rows = assetRows(tables);
|
|
337
|
+
const rowByKey = new Map();
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
if (!row?.source_uri) continue;
|
|
340
|
+
const key = String(row.id || '');
|
|
341
|
+
if (!key || rowByKey.has(key) || row.source_uri !== portableUri(key)) {
|
|
342
|
+
throw fail('invalid-asset-reference');
|
|
343
|
+
}
|
|
344
|
+
rowByKey.set(key, row);
|
|
345
|
+
}
|
|
346
|
+
const payloadByPath = new Map();
|
|
347
|
+
for (const payload of assetBundle.payloads) {
|
|
348
|
+
const path = normalizePayloadPath(payload?.path);
|
|
349
|
+
if (payloadByPath.has(path)) throw fail('duplicate-payload-path');
|
|
350
|
+
payloadByPath.set(path, payload);
|
|
351
|
+
}
|
|
352
|
+
const keys = new Set();
|
|
353
|
+
const paths = new Set();
|
|
354
|
+
const verified = [];
|
|
355
|
+
let totalBytes = 0;
|
|
356
|
+
for (const entry of assetBundle.entries) {
|
|
357
|
+
const key = typeof entry?.key === 'string' ? entry.key : '';
|
|
358
|
+
const payloadPath = normalizePayloadPath(entry?.payloadPath);
|
|
359
|
+
if (!key || key.length > 512 || keys.has(key)) throw fail('duplicate-asset-key');
|
|
360
|
+
if (paths.has(payloadPath)) throw fail('duplicate-payload-path');
|
|
361
|
+
keys.add(key);
|
|
362
|
+
paths.add(payloadPath);
|
|
363
|
+
if (
|
|
364
|
+
!Number.isSafeInteger(entry.byteLength) ||
|
|
365
|
+
entry.byteLength < 0 ||
|
|
366
|
+
entry.byteLength > MAX_ASSET_BYTES
|
|
367
|
+
) {
|
|
368
|
+
throw fail('invalid-asset-size');
|
|
369
|
+
}
|
|
370
|
+
if (!DIGEST_PATTERN.test(entry.contentDigest || '')) {
|
|
371
|
+
throw fail('invalid-asset-digest');
|
|
372
|
+
}
|
|
373
|
+
totalBytes += entry.byteLength;
|
|
374
|
+
if (totalBytes > MAX_TOTAL_ASSET_BYTES) throw fail('asset-total-too-large');
|
|
375
|
+
const payload = payloadByPath.get(payloadPath);
|
|
376
|
+
if (!payload) throw fail('missing-asset-payload');
|
|
377
|
+
const bytes = decodePayload(payload, entry.byteLength);
|
|
378
|
+
if (bytes.byteLength !== entry.byteLength) throw fail('asset-size-mismatch');
|
|
379
|
+
if (digestBytes(bytes) !== entry.contentDigest) throw fail('asset-digest-mismatch');
|
|
380
|
+
if (!rowByKey.has(key)) throw fail('unreferenced-asset-payload');
|
|
381
|
+
verified.push({
|
|
382
|
+
key,
|
|
383
|
+
bytes,
|
|
384
|
+
contentDigest: entry.contentDigest,
|
|
385
|
+
relativePath: destinationPathForKey(key),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (keys.size !== rowByKey.size) throw fail('missing-asset-entry');
|
|
389
|
+
if (payloadByPath.size !== paths.size) throw fail('unreferenced-asset-payload');
|
|
390
|
+
if (verified.length === 0) return { root: null, entries: [] };
|
|
391
|
+
const root = assertAssetRoot({ sourceRoot, assetRoot, allowMissing: true });
|
|
392
|
+
for (const entry of verified) {
|
|
393
|
+
rowByKey.get(entry.key).source_uri = pathToFileURL(join(root, entry.relativePath)).href;
|
|
394
|
+
}
|
|
395
|
+
return { root, entries: verified };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function journalPath(stateRoot, appId) {
|
|
399
|
+
return join(stateRoot, `.smrt-asset-import-${sha256(appId).slice(0, 16)}.json`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function writeJournal(path, journal) {
|
|
403
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
404
|
+
try {
|
|
405
|
+
writeFileSync(temporary, `${JSON.stringify(journal)}\n`, {
|
|
406
|
+
flag: 'wx',
|
|
407
|
+
mode: 0o600,
|
|
408
|
+
});
|
|
409
|
+
renameSync(temporary, path);
|
|
410
|
+
} finally {
|
|
411
|
+
rmSync(temporary, { force: true });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function assertEmptyAssetRoot(root) {
|
|
416
|
+
if (!existsSync(root)) return;
|
|
417
|
+
assertRealDirectory(root, 'unsafe-asset-root');
|
|
418
|
+
if (readdirSync(root).length !== 0) throw fail('non-empty-asset-target');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Stage verified bytes without publishing anything into the destination root. */
|
|
422
|
+
export function stageFilesystemAssets({
|
|
423
|
+
verified,
|
|
424
|
+
stateRoot,
|
|
425
|
+
appId,
|
|
426
|
+
bundleDigest,
|
|
427
|
+
}) {
|
|
428
|
+
if (verified.entries.length === 0) return null;
|
|
429
|
+
assertEmptyAssetRoot(verified.root);
|
|
430
|
+
const stageRoot = join(
|
|
431
|
+
dirname(verified.root),
|
|
432
|
+
`.smrt-asset-stage-${process.pid}-${randomBytes(8).toString('hex')}`,
|
|
433
|
+
);
|
|
434
|
+
const path = journalPath(stateRoot, appId);
|
|
435
|
+
if (existsSync(path)) throw fail('asset-recovery-required');
|
|
436
|
+
const journal = {
|
|
437
|
+
schemaVersion: 1,
|
|
438
|
+
application: appId,
|
|
439
|
+
bundleDigest,
|
|
440
|
+
assetRoot: verified.root,
|
|
441
|
+
stageRoot,
|
|
442
|
+
phase: 'staging',
|
|
443
|
+
rootExisted: existsSync(verified.root),
|
|
444
|
+
entries: verified.entries.map(({ relativePath, contentDigest }) => ({
|
|
445
|
+
relativePath,
|
|
446
|
+
contentDigest,
|
|
447
|
+
})),
|
|
448
|
+
};
|
|
449
|
+
writeJournal(path, journal);
|
|
450
|
+
try {
|
|
451
|
+
mkdirSync(stageRoot, { mode: 0o700 });
|
|
452
|
+
for (const entry of verified.entries) {
|
|
453
|
+
const destination = join(stageRoot, entry.relativePath);
|
|
454
|
+
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
455
|
+
writeFileSync(destination, entry.bytes, { flag: 'wx', mode: 0o600 });
|
|
456
|
+
if (digestBytes(readFileSync(destination)) !== entry.contentDigest) {
|
|
457
|
+
throw fail('staged-asset-digest-mismatch');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { path, journal };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
rmSync(stageRoot, { recursive: true, force: true });
|
|
463
|
+
rmSync(path, { force: true });
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Publish the complete verified tree with one rename that never follows the target. */
|
|
469
|
+
export function publishFilesystemAssets(staged, options = {}) {
|
|
470
|
+
if (!staged) return;
|
|
471
|
+
const { journal, path } = staged;
|
|
472
|
+
assertEmptyAssetRoot(journal.assetRoot);
|
|
473
|
+
journal.phase = 'publishing';
|
|
474
|
+
writeJournal(path, journal);
|
|
475
|
+
if (existsSync(journal.assetRoot)) rmdirSync(journal.assetRoot);
|
|
476
|
+
try {
|
|
477
|
+
options.beforeRename?.();
|
|
478
|
+
renameSync(journal.stageRoot, journal.assetRoot);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (journal.rootExisted && !existsSync(journal.assetRoot)) {
|
|
481
|
+
mkdirSync(journal.assetRoot, { mode: 0o700 });
|
|
482
|
+
}
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
journal.phase = 'published';
|
|
486
|
+
writeJournal(path, journal);
|
|
487
|
+
verifyPublishedTree(journal);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function verifyPublishedEntry(
|
|
491
|
+
journal,
|
|
492
|
+
entry,
|
|
493
|
+
approvedRoot,
|
|
494
|
+
{ allowMissing = false, verifyDigest = true } = {},
|
|
495
|
+
) {
|
|
496
|
+
const destination = join(
|
|
497
|
+
journal.assetRoot,
|
|
498
|
+
normalizeDestinationPath(entry.relativePath),
|
|
499
|
+
);
|
|
500
|
+
if (!existsSync(destination)) {
|
|
501
|
+
if (allowMissing) return false;
|
|
502
|
+
throw fail('missing-published-asset');
|
|
503
|
+
}
|
|
504
|
+
const bytes = readRegularFile(destination, approvedRoot);
|
|
505
|
+
if (
|
|
506
|
+
verifyDigest &&
|
|
507
|
+
digestBytes(bytes) !== entry.contentDigest
|
|
508
|
+
) {
|
|
509
|
+
throw fail('published-asset-digest-mismatch');
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function verifyPublishedTree(journal, { verifyDigest = true } = {}) {
|
|
515
|
+
try {
|
|
516
|
+
const approvedRoot = assertRealDirectory(
|
|
517
|
+
journal.assetRoot,
|
|
518
|
+
'unsafe-published-asset',
|
|
519
|
+
);
|
|
520
|
+
const rootEntries = readdirSync(approvedRoot);
|
|
521
|
+
if (rootEntries.length !== 1 || rootEntries[0] !== 'portable') {
|
|
522
|
+
throw fail('unexpected-published-asset');
|
|
523
|
+
}
|
|
524
|
+
const portableDirectory = join(approvedRoot, 'portable');
|
|
525
|
+
assertRealDirectory(portableDirectory, 'unsafe-published-asset');
|
|
526
|
+
const expected = new Set(
|
|
527
|
+
journal.entries.map((entry) =>
|
|
528
|
+
normalizeDestinationPath(entry.relativePath).slice('portable/'.length),
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
const actual = readdirSync(portableDirectory);
|
|
532
|
+
if (
|
|
533
|
+
actual.length !== expected.size ||
|
|
534
|
+
actual.some((name) => !expected.has(name))
|
|
535
|
+
) {
|
|
536
|
+
throw fail('unexpected-published-asset');
|
|
537
|
+
}
|
|
538
|
+
for (const entry of journal.entries) {
|
|
539
|
+
verifyPublishedEntry(journal, entry, approvedRoot, { verifyDigest });
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (isSanitizedFailure(error)) throw error;
|
|
543
|
+
throw fail('unsafe-published-asset');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function verifyPublishedFilesystemAssets(staged) {
|
|
548
|
+
if (staged) verifyPublishedTree(staged.journal);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function removePublishedTree(journal) {
|
|
552
|
+
const quarantine = `${journal.stageRoot}.rollback-${randomBytes(6).toString('hex')}`;
|
|
553
|
+
renameSync(journal.assetRoot, quarantine);
|
|
554
|
+
const quarantinedJournal = { ...journal, assetRoot: quarantine };
|
|
555
|
+
try {
|
|
556
|
+
verifyPublishedTree(quarantinedJournal, { verifyDigest: false });
|
|
557
|
+
} catch {
|
|
558
|
+
// Never follow or recursively delete a tree that no longer matches the
|
|
559
|
+
// journal. Atomic detachment leaves the actual target retryable while the
|
|
560
|
+
// untrusted replacement remains quarantined for operator inspection.
|
|
561
|
+
if (journal.rootExisted) mkdirSync(journal.assetRoot, { mode: 0o700 });
|
|
562
|
+
return { quarantined: true, path: quarantine };
|
|
563
|
+
}
|
|
564
|
+
rmSync(quarantine, { recursive: true });
|
|
565
|
+
if (journal.rootExisted) mkdirSync(journal.assetRoot, { mode: 0o700 });
|
|
566
|
+
return { quarantined: false };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function finishFilesystemAssets(staged) {
|
|
570
|
+
if (!staged) return;
|
|
571
|
+
verifyPublishedTree(staged.journal);
|
|
572
|
+
rmSync(staged.path, { force: true });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function rollbackFilesystemAssets(staged) {
|
|
576
|
+
if (!staged) return;
|
|
577
|
+
if (existsSync(staged.journal.stageRoot)) {
|
|
578
|
+
rmSync(staged.journal.stageRoot, { recursive: true });
|
|
579
|
+
} else if (existsSync(staged.journal.assetRoot)) {
|
|
580
|
+
removePublishedTree(staged.journal);
|
|
581
|
+
}
|
|
582
|
+
if (staged.journal.rootExisted && !existsSync(staged.journal.assetRoot)) {
|
|
583
|
+
mkdirSync(staged.journal.assetRoot, { mode: 0o700 });
|
|
584
|
+
}
|
|
585
|
+
rmSync(staged.path, { force: true });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Recover a prior crash only when the same bundle and exact target state prove ownership. */
|
|
589
|
+
export function recoverFilesystemAssets({
|
|
590
|
+
stateRoot,
|
|
591
|
+
appId,
|
|
592
|
+
bundleDigest,
|
|
593
|
+
assetRoot,
|
|
594
|
+
targetState,
|
|
595
|
+
}) {
|
|
596
|
+
const path = journalPath(stateRoot, appId);
|
|
597
|
+
if (!existsSync(path)) return 'none';
|
|
598
|
+
const details = lstatSync(path);
|
|
599
|
+
if (details.isSymbolicLink() || !details.isFile() || (details.mode & 0o077) !== 0) {
|
|
600
|
+
throw fail('unsafe-asset-recovery-journal');
|
|
601
|
+
}
|
|
602
|
+
let journal;
|
|
603
|
+
try {
|
|
604
|
+
journal = JSON.parse(readFileSync(path, 'utf8'));
|
|
605
|
+
} catch {
|
|
606
|
+
throw fail('invalid-asset-recovery-journal');
|
|
607
|
+
}
|
|
608
|
+
if (
|
|
609
|
+
journal?.schemaVersion !== 1 ||
|
|
610
|
+
journal.application !== appId ||
|
|
611
|
+
journal.bundleDigest !== bundleDigest ||
|
|
612
|
+
resolve(journal.assetRoot || '') !== resolve(assetRoot || '') ||
|
|
613
|
+
!Array.isArray(journal.entries) ||
|
|
614
|
+
!['staging', 'publishing', 'published'].includes(journal.phase) ||
|
|
615
|
+
dirname(resolve(journal.stageRoot || '')) !== dirname(resolve(assetRoot || '')) ||
|
|
616
|
+
!String(journal.stageRoot || '').includes('.smrt-asset-stage-')
|
|
617
|
+
) {
|
|
618
|
+
throw fail('asset-recovery-mismatch');
|
|
619
|
+
}
|
|
620
|
+
if (targetState === 'complete') {
|
|
621
|
+
if (existsSync(journal.stageRoot)) throw fail('asset-recovery-target-mismatch');
|
|
622
|
+
verifyPublishedTree(journal);
|
|
623
|
+
rmSync(path, { force: true });
|
|
624
|
+
return 'complete';
|
|
625
|
+
}
|
|
626
|
+
if (targetState !== 'empty') throw fail('asset-recovery-target-mismatch');
|
|
627
|
+
if (existsSync(journal.stageRoot)) {
|
|
628
|
+
rmSync(journal.stageRoot, { recursive: true });
|
|
629
|
+
} else if (existsSync(journal.assetRoot)) {
|
|
630
|
+
removePublishedTree(journal);
|
|
631
|
+
}
|
|
632
|
+
if (journal.rootExisted && !existsSync(journal.assetRoot)) {
|
|
633
|
+
mkdirSync(journal.assetRoot, { mode: 0o700 });
|
|
634
|
+
}
|
|
635
|
+
rmSync(path, { force: true });
|
|
636
|
+
return 'retry';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function readSensitiveBundle(path, options = {}) {
|
|
640
|
+
let details;
|
|
641
|
+
let initialRealPath;
|
|
642
|
+
try {
|
|
643
|
+
details = lstatSync(path);
|
|
644
|
+
initialRealPath = realpathSync(path);
|
|
645
|
+
} catch {
|
|
646
|
+
throw fail('unsafe-bundle-file');
|
|
647
|
+
}
|
|
648
|
+
if (
|
|
649
|
+
details.isSymbolicLink() ||
|
|
650
|
+
!details.isFile() ||
|
|
651
|
+
initialRealPath !== resolve(path) ||
|
|
652
|
+
details.size > MAX_BUNDLE_BYTES ||
|
|
653
|
+
(process.platform !== 'win32' && (details.mode & 0o777) !== 0o600)
|
|
654
|
+
) {
|
|
655
|
+
throw fail('unsafe-bundle-file');
|
|
656
|
+
}
|
|
657
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
658
|
+
let descriptor;
|
|
659
|
+
try {
|
|
660
|
+
descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
661
|
+
} catch {
|
|
662
|
+
throw fail('bundle-changed-during-read');
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const opened = fstatSync(descriptor);
|
|
666
|
+
if (
|
|
667
|
+
opened.dev !== details.dev ||
|
|
668
|
+
opened.ino !== details.ino ||
|
|
669
|
+
opened.size !== details.size ||
|
|
670
|
+
!opened.isFile()
|
|
671
|
+
) {
|
|
672
|
+
throw fail('bundle-changed-during-read');
|
|
673
|
+
}
|
|
674
|
+
options.onOpened?.();
|
|
675
|
+
const bytes = readBoundedDescriptor(
|
|
676
|
+
descriptor,
|
|
677
|
+
opened.size,
|
|
678
|
+
'bundle-changed-during-read',
|
|
679
|
+
);
|
|
680
|
+
const after = fstatSync(descriptor);
|
|
681
|
+
let afterPath;
|
|
682
|
+
let finalRealPath;
|
|
683
|
+
try {
|
|
684
|
+
afterPath = statSync(path);
|
|
685
|
+
finalRealPath = realpathSync(path);
|
|
686
|
+
} catch {
|
|
687
|
+
throw fail('bundle-changed-during-read');
|
|
688
|
+
}
|
|
689
|
+
if (
|
|
690
|
+
after.dev !== opened.dev ||
|
|
691
|
+
after.ino !== opened.ino ||
|
|
692
|
+
after.size !== opened.size ||
|
|
693
|
+
afterPath.dev !== opened.dev ||
|
|
694
|
+
afterPath.ino !== opened.ino ||
|
|
695
|
+
finalRealPath !== resolve(path)
|
|
696
|
+
) {
|
|
697
|
+
throw fail('bundle-changed-during-read');
|
|
698
|
+
}
|
|
699
|
+
return bytes.toString('utf8');
|
|
700
|
+
} finally {
|
|
701
|
+
closeSync(descriptor);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function bundleContentDigest(serialized) {
|
|
706
|
+
return digestBytes(Buffer.from(serialized));
|
|
707
|
+
}
|