@file-viewer/asset-installer 3.0.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/LICENSE +160 -0
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +37 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +1073 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, existsSync } from 'node:fs';
|
|
3
|
+
import { cp, link, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
export const runtimeManifestFilename = 'flyfish-viewer-assets.json';
|
|
8
|
+
export const assetLedgerFilename = 'file-viewer-assets.ledger.json';
|
|
9
|
+
export const assetLockFilename = '.file-viewer-assets.lock';
|
|
10
|
+
const assetLockReaperFilename = '.file-viewer-assets.lock.reaper';
|
|
11
|
+
const assetLockWaitMs = 30_000;
|
|
12
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const normalizePath = (value) => {
|
|
14
|
+
const normalized = String(value).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
15
|
+
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
16
|
+
throw new Error(`Unsafe capability asset path ${JSON.stringify(value)}.`);
|
|
17
|
+
}
|
|
18
|
+
return normalized;
|
|
19
|
+
};
|
|
20
|
+
const contained = (root, path) => {
|
|
21
|
+
const target = resolve(root, normalizePath(path));
|
|
22
|
+
const relation = relative(root, target);
|
|
23
|
+
if (!relation || relation.startsWith('..') || resolve(root, relation) !== target) {
|
|
24
|
+
throw new Error(`Capability asset path escapes target: ${path}.`);
|
|
25
|
+
}
|
|
26
|
+
return target;
|
|
27
|
+
};
|
|
28
|
+
const nearestExistingParent = async (path) => {
|
|
29
|
+
const missing = [];
|
|
30
|
+
let cursor = resolve(path);
|
|
31
|
+
while (true) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await lstat(cursor);
|
|
34
|
+
if (info.isSymbolicLink())
|
|
35
|
+
throw new Error(`Refusing symbolic-link asset path ${cursor}.`);
|
|
36
|
+
if (!info.isDirectory())
|
|
37
|
+
throw new Error(`Asset target parent is not a directory: ${cursor}.`);
|
|
38
|
+
return { existing: cursor, missing };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT')
|
|
42
|
+
throw error;
|
|
43
|
+
const parent = dirname(cursor);
|
|
44
|
+
if (parent === cursor)
|
|
45
|
+
throw error;
|
|
46
|
+
missing.unshift(basename(cursor));
|
|
47
|
+
cursor = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** Resolve through the nearest existing ancestor once, then keep every later path inside that canonical root. */
|
|
52
|
+
const prepareTargetDir = async (targetDirInput) => {
|
|
53
|
+
const requested = resolve(targetDirInput);
|
|
54
|
+
const { existing, missing } = await nearestExistingParent(requested);
|
|
55
|
+
const canonicalParent = await realpath(existing);
|
|
56
|
+
const canonicalTarget = missing.reduce((path, segment) => resolve(path, segment), canonicalParent);
|
|
57
|
+
await mkdir(canonicalTarget, { recursive: true });
|
|
58
|
+
const info = await lstat(canonicalTarget);
|
|
59
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
60
|
+
throw new Error(`Refusing non-directory asset target ${canonicalTarget}.`);
|
|
61
|
+
return realpath(canonicalTarget);
|
|
62
|
+
};
|
|
63
|
+
const assertNoSymlinkPath = async (root, path) => {
|
|
64
|
+
let cursor = root;
|
|
65
|
+
for (const segment of normalizePath(path).split('/')) {
|
|
66
|
+
cursor = resolve(cursor, segment);
|
|
67
|
+
try {
|
|
68
|
+
if ((await lstat(cursor)).isSymbolicLink()) {
|
|
69
|
+
throw new Error(`Refusing to follow symbolic link inside asset target: ${relative(root, cursor)}.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT')
|
|
74
|
+
return;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const listFiles = async (root, current = root) => {
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
82
|
+
const path = resolve(current, entry.name);
|
|
83
|
+
if (entry.isSymbolicLink())
|
|
84
|
+
throw new Error(`Capability asset pack contains a symbolic link: ${relative(root, path)}.`);
|
|
85
|
+
if (entry.isDirectory())
|
|
86
|
+
files.push(...await listFiles(root, path));
|
|
87
|
+
else if (entry.isFile())
|
|
88
|
+
files.push(normalizePath(relative(root, path)));
|
|
89
|
+
}
|
|
90
|
+
return files.sort();
|
|
91
|
+
};
|
|
92
|
+
const readNoFollow = async (path) => {
|
|
93
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
94
|
+
try {
|
|
95
|
+
return await handle.readFile();
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await handle.close();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const readTextNoFollow = async (path) => {
|
|
102
|
+
try {
|
|
103
|
+
return (await readNoFollow(path)).toString('utf8');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === 'ENOENT')
|
|
107
|
+
return null;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const hashFile = async (path) => sha256(await readNoFollow(path));
|
|
112
|
+
const inspectFile = async (root, path) => {
|
|
113
|
+
const absolute = contained(root, path);
|
|
114
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
115
|
+
try {
|
|
116
|
+
const info = await handle.stat();
|
|
117
|
+
if (!info.isFile())
|
|
118
|
+
throw new Error(`Capability asset is not a regular file: ${path}.`);
|
|
119
|
+
return { path, size: info.size, sha256: sha256(await handle.readFile()) };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await handle.close();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const copyFileNoFollow = async (source, destination) => {
|
|
126
|
+
const sourceHandle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
127
|
+
let destinationHandle;
|
|
128
|
+
try {
|
|
129
|
+
const info = await sourceHandle.stat();
|
|
130
|
+
if (!info.isFile())
|
|
131
|
+
throw new Error(`Asset source is not a regular file: ${source}.`);
|
|
132
|
+
destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, info.mode & 0o777);
|
|
133
|
+
await destinationHandle.writeFile(await sourceHandle.readFile());
|
|
134
|
+
await destinationHandle.sync();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await destinationHandle?.close();
|
|
138
|
+
await sourceHandle.close();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const atomicWrite = async (root, path, value) => {
|
|
142
|
+
await assertNoSymlinkPath(root, normalizePath(relative(root, path)));
|
|
143
|
+
await mkdir(dirname(path), { recursive: true });
|
|
144
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
145
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
|
|
146
|
+
try {
|
|
147
|
+
await handle.writeFile(value, 'utf8');
|
|
148
|
+
await handle.sync();
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await handle.close();
|
|
152
|
+
}
|
|
153
|
+
await rename(temporary, path);
|
|
154
|
+
};
|
|
155
|
+
const processIsAlive = (pid) => {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, 0);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return error.code === 'EPERM';
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const parseLockState = async (path) => {
|
|
165
|
+
const info = await lstat(path);
|
|
166
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
167
|
+
throw new Error(`Invalid File Viewer asset lock at ${path}.`);
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse((await readNoFollow(path)).toString('utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (Date.now() - info.mtimeMs > 5 * 60_000)
|
|
174
|
+
return { stale: true, dev: info.dev, ino: info.ino };
|
|
175
|
+
throw new Error(`Invalid active File Viewer asset lock at ${path}: ${error.message}`, { cause: error });
|
|
176
|
+
}
|
|
177
|
+
const createdAt = typeof parsed.createdAt === 'string' ? Date.parse(parsed.createdAt) : Number.NaN;
|
|
178
|
+
const age = Date.now() - Math.max(info.mtimeMs, Number.isFinite(createdAt) ? createdAt : 0);
|
|
179
|
+
return {
|
|
180
|
+
stale: age > 5 * 60_000 && (!Number.isSafeInteger(parsed.pid) || !processIsAlive(parsed.pid)),
|
|
181
|
+
dev: info.dev,
|
|
182
|
+
ino: info.ino,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const restoreQuarantinedLock = async (quarantinePath, originalPath) => {
|
|
186
|
+
try {
|
|
187
|
+
// An atomic no-replace restore cannot overwrite a newer claimant.
|
|
188
|
+
await link(quarantinePath, originalPath);
|
|
189
|
+
await rm(quarantinePath, { force: true });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code === 'EEXIST') {
|
|
193
|
+
throw new Error(`A live File Viewer asset lock replaced ${originalPath} while its stale predecessor was quarantined.`, { cause: error });
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const reclaimStaleAuxiliaryLock = async (targetDir, path) => {
|
|
199
|
+
const before = await parseLockState(path);
|
|
200
|
+
if (!before.stale)
|
|
201
|
+
return false;
|
|
202
|
+
const quarantinePath = resolve(targetDir, `${basename(path)}.stale.${process.pid}.${Date.now()}.${randomUUID()}`);
|
|
203
|
+
try {
|
|
204
|
+
await rename(path, quarantinePath);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code === 'ENOENT')
|
|
208
|
+
return false;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const after = await parseLockState(quarantinePath);
|
|
213
|
+
if (after.dev !== before.dev || after.ino !== before.ino || !after.stale) {
|
|
214
|
+
await restoreQuarantinedLock(quarantinePath, path);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
await rm(quarantinePath, { force: true });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (existsSync(quarantinePath)) {
|
|
222
|
+
try {
|
|
223
|
+
await restoreQuarantinedLock(quarantinePath, path);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Keep the quarantined inode and fail closed if another process won.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const acquireAuxiliaryAssetLock = async (targetDir, path) => {
|
|
233
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
234
|
+
const token = randomUUID();
|
|
235
|
+
const content = `${JSON.stringify({
|
|
236
|
+
schemaVersion: 1,
|
|
237
|
+
pid: process.pid,
|
|
238
|
+
createdAt: new Date().toISOString(),
|
|
239
|
+
token,
|
|
240
|
+
})}\n`;
|
|
241
|
+
let handle;
|
|
242
|
+
try {
|
|
243
|
+
handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
244
|
+
await handle.writeFile(content, 'utf8');
|
|
245
|
+
await handle.sync();
|
|
246
|
+
await handle.close();
|
|
247
|
+
return { path, token };
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await handle?.close();
|
|
251
|
+
if (error.code !== 'EEXIST')
|
|
252
|
+
throw error;
|
|
253
|
+
await assertNoSymlinkPath(targetDir, normalizePath(relative(targetDir, path)));
|
|
254
|
+
try {
|
|
255
|
+
if ((await parseLockState(path)).stale && await reclaimStaleAuxiliaryLock(targetDir, path))
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
catch (readError) {
|
|
259
|
+
if (readError.code === 'ENOENT')
|
|
260
|
+
continue;
|
|
261
|
+
throw readError;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
};
|
|
268
|
+
const releaseAuxiliaryAssetLock = async (lock) => {
|
|
269
|
+
try {
|
|
270
|
+
const parsed = JSON.parse((await readNoFollow(lock.path)).toString('utf8'));
|
|
271
|
+
if (parsed.token === lock.token)
|
|
272
|
+
await rm(lock.path, { force: true });
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error.code !== 'ENOENT')
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const quarantineStaleAssetLock = async (targetDir, lockPath) => {
|
|
280
|
+
const claimPath = resolve(targetDir, assetLockReaperFilename);
|
|
281
|
+
await assertNoSymlinkPath(targetDir, assetLockReaperFilename);
|
|
282
|
+
const claim = await acquireAuxiliaryAssetLock(targetDir, claimPath);
|
|
283
|
+
if (!claim)
|
|
284
|
+
return false;
|
|
285
|
+
const quarantinePath = resolve(targetDir, `${assetLockFilename}.stale.${process.pid}.${Date.now()}.${randomUUID()}`);
|
|
286
|
+
try {
|
|
287
|
+
try {
|
|
288
|
+
if (!(await parseLockState(lockPath)).stale)
|
|
289
|
+
return false;
|
|
290
|
+
// The reaper claim ensures no second stale branch can race this rename.
|
|
291
|
+
// New contenders may create a fresh lock after the rename, but cleanup only
|
|
292
|
+
// touches the uniquely named quarantined inode.
|
|
293
|
+
await rename(lockPath, quarantinePath);
|
|
294
|
+
await rm(quarantinePath, { force: true });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error.code === 'ENOENT')
|
|
299
|
+
return false;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
await releaseAuxiliaryAssetLock(claim);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const acquireAssetLock = async (targetDir) => {
|
|
308
|
+
const path = resolve(targetDir, assetLockFilename);
|
|
309
|
+
await assertNoSymlinkPath(targetDir, assetLockFilename);
|
|
310
|
+
const token = randomUUID();
|
|
311
|
+
const content = `${JSON.stringify({ schemaVersion: 1, pid: process.pid, createdAt: new Date().toISOString(), token })}\n`;
|
|
312
|
+
const deadline = Date.now() + assetLockWaitMs;
|
|
313
|
+
let attempt = 0;
|
|
314
|
+
while (Date.now() < deadline) {
|
|
315
|
+
try {
|
|
316
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
317
|
+
try {
|
|
318
|
+
await handle.writeFile(content, 'utf8');
|
|
319
|
+
await handle.sync();
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
await handle.close();
|
|
323
|
+
}
|
|
324
|
+
return { path, token, content };
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error.code !== 'EEXIST')
|
|
328
|
+
throw error;
|
|
329
|
+
await assertNoSymlinkPath(targetDir, assetLockFilename);
|
|
330
|
+
try {
|
|
331
|
+
if ((await parseLockState(path)).stale && await quarantineStaleAssetLock(targetDir, path))
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
catch (readError) {
|
|
335
|
+
if (readError.code === 'ENOENT')
|
|
336
|
+
continue;
|
|
337
|
+
throw readError;
|
|
338
|
+
}
|
|
339
|
+
await delay(Math.min(250, 25 * (2 ** Math.min(attempt, 4))));
|
|
340
|
+
attempt += 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw new Error(`Timed out after ${assetLockWaitMs / 1000}s waiting for the File Viewer asset lock in ${targetDir}.`);
|
|
344
|
+
};
|
|
345
|
+
const releaseAssetLock = async (lock) => {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse((await readNoFollow(lock.path)).toString('utf8'));
|
|
348
|
+
if (parsed.token !== lock.token)
|
|
349
|
+
throw new Error(`File Viewer asset lock ownership changed at ${lock.path}.`);
|
|
350
|
+
await rm(lock.path, { force: true });
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (error.code !== 'ENOENT')
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const stableJson = (value) => {
|
|
358
|
+
if (Array.isArray(value))
|
|
359
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
360
|
+
if (value && typeof value === 'object') {
|
|
361
|
+
return `{${Object.entries(value)
|
|
362
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
363
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
364
|
+
.join(',')}}`;
|
|
365
|
+
}
|
|
366
|
+
return JSON.stringify(value);
|
|
367
|
+
};
|
|
368
|
+
const sha256Pattern = /^[a-f0-9]{64}$/;
|
|
369
|
+
const rendererIdPattern = /^[a-z0-9-]+$/;
|
|
370
|
+
const unsafeObjectKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
371
|
+
const validateJsonValue = (value, label, seen = new Set()) => {
|
|
372
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
373
|
+
return;
|
|
374
|
+
if (typeof value === 'number') {
|
|
375
|
+
if (!Number.isFinite(value))
|
|
376
|
+
throw new Error(`${label} contains a non-finite number.`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (typeof value !== 'object')
|
|
380
|
+
throw new Error(`${label} contains a non-JSON value.`);
|
|
381
|
+
if (seen.has(value))
|
|
382
|
+
throw new Error(`${label} contains a cycle.`);
|
|
383
|
+
seen.add(value);
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
for (const item of value)
|
|
386
|
+
validateJsonValue(item, label, seen);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
const prototype = Object.getPrototypeOf(value);
|
|
390
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
391
|
+
throw new Error(`${label} has an unsafe prototype.`);
|
|
392
|
+
for (const [key, item] of Object.entries(value)) {
|
|
393
|
+
if (unsafeObjectKeys.has(key))
|
|
394
|
+
throw new Error(`${label} contains unsafe key ${key}.`);
|
|
395
|
+
validateJsonValue(item, label, seen);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
seen.delete(value);
|
|
399
|
+
};
|
|
400
|
+
const validateRendererManifest = (manifest, label) => {
|
|
401
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
402
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
403
|
+
}
|
|
404
|
+
validateJsonValue(manifest, label);
|
|
405
|
+
const record = manifest;
|
|
406
|
+
if (typeof record.rendererId !== 'string' || !rendererIdPattern.test(record.rendererId)) {
|
|
407
|
+
throw new Error(`${label} has an invalid rendererId.`);
|
|
408
|
+
}
|
|
409
|
+
return record;
|
|
410
|
+
};
|
|
411
|
+
const validateReceipt = (receipt, path) => {
|
|
412
|
+
if (receipt.schemaVersion !== 1 || typeof receipt.packageName !== 'string' || !receipt.packageName ||
|
|
413
|
+
typeof receipt.packageVersion !== 'string' || !receipt.packageVersion ||
|
|
414
|
+
!sha256Pattern.test(receipt.assetManifestSha256) || !Array.isArray(receipt.copyGroups) ||
|
|
415
|
+
!Array.isArray(receipt.files)) {
|
|
416
|
+
throw new Error(`Invalid File Viewer asset receipt: ${path}.`);
|
|
417
|
+
}
|
|
418
|
+
if (receipt.copyGroups.some(group => typeof group !== 'string' || !group) ||
|
|
419
|
+
new Set(receipt.copyGroups).size !== receipt.copyGroups.length ||
|
|
420
|
+
(receipt.profile !== undefined && (typeof receipt.profile !== 'string' || !receipt.profile)) ||
|
|
421
|
+
(receipt.profileManifestSha256 !== undefined && !sha256Pattern.test(receipt.profileManifestSha256)) ||
|
|
422
|
+
typeof receipt.installedAt !== 'string' || !Number.isFinite(Date.parse(receipt.installedAt))) {
|
|
423
|
+
throw new Error(`Invalid File Viewer asset receipt metadata: ${path}.`);
|
|
424
|
+
}
|
|
425
|
+
const seen = new Set();
|
|
426
|
+
for (const file of receipt.files) {
|
|
427
|
+
const normalized = normalizePath(file.path);
|
|
428
|
+
if (normalized !== file.path || seen.has(normalized) || !Number.isSafeInteger(file.size) || file.size < 0 ||
|
|
429
|
+
!sha256Pattern.test(file.sha256) || !['managed', 'shared'].includes(file.ownership)) {
|
|
430
|
+
throw new Error(`Invalid File Viewer asset receipt file entry in ${path}: ${String(file.path)}.`);
|
|
431
|
+
}
|
|
432
|
+
seen.add(normalized);
|
|
433
|
+
}
|
|
434
|
+
return receipt;
|
|
435
|
+
};
|
|
436
|
+
const readReceipt = async (path) => {
|
|
437
|
+
try {
|
|
438
|
+
const receipt = JSON.parse((await readNoFollow(path)).toString('utf8'));
|
|
439
|
+
return validateReceipt(receipt, path);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (error.code === 'ENOENT')
|
|
443
|
+
return null;
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
const validateLedger = (ledger, path) => {
|
|
448
|
+
if (ledger.schemaVersion !== 1 || !Array.isArray(ledger.entries) || !Array.isArray(ledger.paths)) {
|
|
449
|
+
throw new Error(`Invalid File Viewer asset ledger: ${path}.`);
|
|
450
|
+
}
|
|
451
|
+
const seenEntries = new Set();
|
|
452
|
+
for (const entry of ledger.entries) {
|
|
453
|
+
const manifestIsObject = Boolean(entry.manifest) && typeof entry.manifest === 'object' && !Array.isArray(entry.manifest);
|
|
454
|
+
if (typeof entry.rendererId !== 'string' || !entry.rendererId ||
|
|
455
|
+
typeof entry.ownerPackage !== 'string' || !entry.ownerPackage ||
|
|
456
|
+
typeof entry.ownerVersion !== 'string' || !entry.ownerVersion ||
|
|
457
|
+
typeof entry.receiptFilename !== 'string' || !entry.receiptFilename ||
|
|
458
|
+
!sha256Pattern.test(entry.sha256) || !manifestIsObject || entry.manifest.rendererId !== entry.rendererId ||
|
|
459
|
+
entry.sha256 !== sha256(stableJson(entry.manifest))) {
|
|
460
|
+
throw new Error(`Invalid File Viewer asset ledger entry in ${path}.`);
|
|
461
|
+
}
|
|
462
|
+
if (normalizePath(entry.receiptFilename) !== entry.receiptFilename) {
|
|
463
|
+
throw new Error(`Invalid File Viewer asset ledger receipt path in ${path}.`);
|
|
464
|
+
}
|
|
465
|
+
validateRendererManifest(entry.manifest, `File Viewer asset ledger entry ${entry.rendererId}`);
|
|
466
|
+
if (seenEntries.has(entry.rendererId))
|
|
467
|
+
throw new Error(`Duplicate File Viewer asset ledger renderer in ${path}.`);
|
|
468
|
+
seenEntries.add(entry.rendererId);
|
|
469
|
+
}
|
|
470
|
+
const seenPaths = new Set();
|
|
471
|
+
for (const item of ledger.paths) {
|
|
472
|
+
if (normalizePath(item.path) !== item.path || seenPaths.has(item.path) || !Number.isSafeInteger(item.size) || item.size < 0 ||
|
|
473
|
+
!sha256Pattern.test(item.sha256) || !Array.isArray(item.owners) || !item.owners.length) {
|
|
474
|
+
throw new Error(`Invalid File Viewer asset ledger path in ${path}: ${String(item.path)}.`);
|
|
475
|
+
}
|
|
476
|
+
seenPaths.add(item.path);
|
|
477
|
+
const seenOwners = new Set();
|
|
478
|
+
for (const owner of item.owners) {
|
|
479
|
+
if (typeof owner.packageName !== 'string' || !owner.packageName ||
|
|
480
|
+
typeof owner.receiptFilename !== 'string' || !owner.receiptFilename ||
|
|
481
|
+
!['managed', 'shared'].includes(owner.ownership)) {
|
|
482
|
+
throw new Error(`Invalid File Viewer asset ledger owner in ${path}: ${item.path}.`);
|
|
483
|
+
}
|
|
484
|
+
if (normalizePath(owner.receiptFilename) !== owner.receiptFilename || seenOwners.has(owner.packageName)) {
|
|
485
|
+
throw new Error(`Invalid or duplicate File Viewer asset ledger owner in ${path}: ${item.path}.`);
|
|
486
|
+
}
|
|
487
|
+
seenOwners.add(owner.packageName);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return ledger;
|
|
491
|
+
};
|
|
492
|
+
const resolveExistingTargetDir = async (targetDirInput) => {
|
|
493
|
+
const requested = resolve(targetDirInput);
|
|
494
|
+
try {
|
|
495
|
+
const info = await lstat(requested);
|
|
496
|
+
if (info.isSymbolicLink())
|
|
497
|
+
throw new Error(`Refusing symbolic-link asset target ${requested}.`);
|
|
498
|
+
if (!info.isDirectory())
|
|
499
|
+
throw new Error(`Asset target is not a directory: ${requested}.`);
|
|
500
|
+
return realpath(requested);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (error.code === 'ENOENT')
|
|
504
|
+
return null;
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
export const readFileViewerAssetLedger = async (targetDirInput) => {
|
|
509
|
+
const targetDir = await resolveExistingTargetDir(targetDirInput);
|
|
510
|
+
if (!targetDir)
|
|
511
|
+
return { schemaVersion: 1, entries: [], paths: [] };
|
|
512
|
+
await assertNoSymlinkPath(targetDir, assetLedgerFilename);
|
|
513
|
+
try {
|
|
514
|
+
const ledger = JSON.parse((await readNoFollow(resolve(targetDir, assetLedgerFilename))).toString('utf8'));
|
|
515
|
+
if (ledger.schemaVersion === 1 && Array.isArray(ledger.entries) && !Array.isArray(ledger.paths)) {
|
|
516
|
+
return validateLedger({ schemaVersion: 1, entries: ledger.entries, paths: [] }, resolve(targetDir, assetLedgerFilename));
|
|
517
|
+
}
|
|
518
|
+
return validateLedger(ledger, resolve(targetDir, assetLedgerFilename));
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (error.code !== 'ENOENT')
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
await assertNoSymlinkPath(targetDir, runtimeManifestFilename);
|
|
525
|
+
try {
|
|
526
|
+
const manifest = JSON.parse((await readNoFollow(resolve(targetDir, runtimeManifestFilename))).toString('utf8'));
|
|
527
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.rendererAssetManifests)) {
|
|
528
|
+
throw new Error(`Invalid legacy File Viewer runtime asset manifest in ${targetDir}.`);
|
|
529
|
+
}
|
|
530
|
+
const seen = new Set();
|
|
531
|
+
const rendererAssetManifests = manifest.rendererAssetManifests.map(item => {
|
|
532
|
+
const validated = validateRendererManifest(item, 'Legacy File Viewer runtime asset manifest');
|
|
533
|
+
if (seen.has(validated.rendererId))
|
|
534
|
+
throw new Error(`Duplicate legacy asset renderer ${validated.rendererId}.`);
|
|
535
|
+
seen.add(validated.rendererId);
|
|
536
|
+
return validated;
|
|
537
|
+
});
|
|
538
|
+
return {
|
|
539
|
+
schemaVersion: 1,
|
|
540
|
+
entries: rendererAssetManifests.map(item => ({
|
|
541
|
+
rendererId: item.rendererId,
|
|
542
|
+
ownerPackage: '@file-viewer/assets-standard',
|
|
543
|
+
ownerVersion: 'unknown',
|
|
544
|
+
receiptFilename: 'file-viewer-assets-standard.receipt.json',
|
|
545
|
+
sha256: sha256(stableJson(item)),
|
|
546
|
+
manifest: item,
|
|
547
|
+
})),
|
|
548
|
+
paths: [],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error.code !== 'ENOENT')
|
|
553
|
+
throw error;
|
|
554
|
+
return { schemaVersion: 1, entries: [], paths: [] };
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
export async function verifyFileViewerAssetState(targetDirInput) {
|
|
558
|
+
const targetDir = await resolveExistingTargetDir(targetDirInput);
|
|
559
|
+
if (!targetDir) {
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
errors: [`Missing asset target ${resolve(targetDirInput)}.`],
|
|
563
|
+
ledger: null,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const errors = [];
|
|
567
|
+
let ledger = null;
|
|
568
|
+
try {
|
|
569
|
+
for (const path of [assetLedgerFilename, runtimeManifestFilename])
|
|
570
|
+
await assertNoSymlinkPath(targetDir, path);
|
|
571
|
+
const ledgerSource = await readTextNoFollow(resolve(targetDir, assetLedgerFilename));
|
|
572
|
+
if (ledgerSource === null)
|
|
573
|
+
throw new Error(`Missing ${assetLedgerFilename}.`);
|
|
574
|
+
ledger = validateLedger(JSON.parse(ledgerSource), resolve(targetDir, assetLedgerFilename));
|
|
575
|
+
const runtimeSource = await readTextNoFollow(resolve(targetDir, runtimeManifestFilename));
|
|
576
|
+
if (runtimeSource === null)
|
|
577
|
+
throw new Error(`Missing ${runtimeManifestFilename}.`);
|
|
578
|
+
const actualRuntime = JSON.parse(runtimeSource);
|
|
579
|
+
const expectedRuntime = runtimeManifestFromLedger(ledger);
|
|
580
|
+
if (stableJson(actualRuntime) !== stableJson(expectedRuntime)) {
|
|
581
|
+
errors.push(`${runtimeManifestFilename} does not match the signed owner ledger.`);
|
|
582
|
+
}
|
|
583
|
+
for (const item of ledger.paths) {
|
|
584
|
+
try {
|
|
585
|
+
await assertNoSymlinkPath(targetDir, item.path);
|
|
586
|
+
const inspected = await inspectFile(targetDir, item.path);
|
|
587
|
+
if (inspected.size !== item.size || inspected.sha256 !== item.sha256) {
|
|
588
|
+
errors.push(`Asset ledger integrity verification failed: ${item.path}.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
errors.push(`Missing or unsafe ledger asset: ${item.path}.`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
errors.push(error.message);
|
|
598
|
+
}
|
|
599
|
+
return { ok: errors.length === 0, errors, ledger };
|
|
600
|
+
}
|
|
601
|
+
const nextLedgerForOwner = (ledger, input) => {
|
|
602
|
+
if (typeof input.ownerPackage !== 'string' || !input.ownerPackage ||
|
|
603
|
+
typeof input.ownerVersion !== 'string' || !input.ownerVersion ||
|
|
604
|
+
typeof input.receiptFilename !== 'string' || normalizePath(input.receiptFilename) !== input.receiptFilename ||
|
|
605
|
+
!Array.isArray(input.rendererAssetManifests) || !Array.isArray(input.files)) {
|
|
606
|
+
throw new Error('Invalid File Viewer asset owner input.');
|
|
607
|
+
}
|
|
608
|
+
const seenIncoming = new Set();
|
|
609
|
+
const rendererAssetManifests = input.rendererAssetManifests.map(manifest => {
|
|
610
|
+
const validated = validateRendererManifest(manifest, `Asset owner ${input.ownerPackage}`);
|
|
611
|
+
if (seenIncoming.has(validated.rendererId))
|
|
612
|
+
throw new Error(`Duplicate asset renderer ${validated.rendererId} for ${input.ownerPackage}.`);
|
|
613
|
+
seenIncoming.add(validated.rendererId);
|
|
614
|
+
return validated;
|
|
615
|
+
});
|
|
616
|
+
const seenFiles = new Set();
|
|
617
|
+
for (const file of input.files) {
|
|
618
|
+
if (typeof file.path !== 'string' || normalizePath(file.path) !== file.path || seenFiles.has(file.path) ||
|
|
619
|
+
!Number.isSafeInteger(file.size) || file.size < 0 || !sha256Pattern.test(file.sha256) ||
|
|
620
|
+
!['managed', 'shared'].includes(file.ownership)) {
|
|
621
|
+
throw new Error(`Invalid asset file input for ${input.ownerPackage}: ${String(file.path)}.`);
|
|
622
|
+
}
|
|
623
|
+
seenFiles.add(file.path);
|
|
624
|
+
}
|
|
625
|
+
const retainedEntries = ledger.entries.filter(entry => entry.ownerPackage !== input.ownerPackage);
|
|
626
|
+
const incomingEntries = rendererAssetManifests.map(manifest => ({
|
|
627
|
+
rendererId: manifest.rendererId,
|
|
628
|
+
ownerPackage: input.ownerPackage,
|
|
629
|
+
ownerVersion: input.ownerVersion,
|
|
630
|
+
receiptFilename: input.receiptFilename,
|
|
631
|
+
sha256: sha256(stableJson(manifest)),
|
|
632
|
+
manifest,
|
|
633
|
+
}));
|
|
634
|
+
for (const entry of incomingEntries) {
|
|
635
|
+
const conflict = retainedEntries.find(item => item.rendererId === entry.rendererId);
|
|
636
|
+
if (conflict)
|
|
637
|
+
throw new Error(`Asset manifest group ${entry.rendererId} conflicts with ${conflict.ownerPackage}.`);
|
|
638
|
+
}
|
|
639
|
+
const pathMap = new Map();
|
|
640
|
+
for (const item of ledger.paths) {
|
|
641
|
+
const owners = item.owners.filter(owner => owner.packageName !== input.ownerPackage);
|
|
642
|
+
if (owners.length)
|
|
643
|
+
pathMap.set(item.path, { ...item, owners });
|
|
644
|
+
}
|
|
645
|
+
for (const file of input.files) {
|
|
646
|
+
const current = pathMap.get(file.path);
|
|
647
|
+
if (current && (current.sha256 !== file.sha256 || current.size !== file.size)) {
|
|
648
|
+
throw new Error(`Asset path ${file.path} conflicts with ${current.owners[0]?.packageName || 'another owner'}.`);
|
|
649
|
+
}
|
|
650
|
+
const owners = [
|
|
651
|
+
...(current?.owners || []),
|
|
652
|
+
{ packageName: input.ownerPackage, receiptFilename: input.receiptFilename, ownership: file.ownership },
|
|
653
|
+
].sort((left, right) => left.packageName.localeCompare(right.packageName));
|
|
654
|
+
pathMap.set(file.path, { path: file.path, size: file.size, sha256: file.sha256, owners });
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
schemaVersion: 1,
|
|
658
|
+
entries: [...retainedEntries, ...incomingEntries].sort((left, right) => left.rendererId.localeCompare(right.rendererId) || left.ownerPackage.localeCompare(right.ownerPackage)),
|
|
659
|
+
paths: [...pathMap.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
const runtimeManifestFromLedger = (ledger) => {
|
|
663
|
+
const byRendererId = new Map();
|
|
664
|
+
for (const entry of ledger.entries) {
|
|
665
|
+
const existing = byRendererId.get(entry.rendererId);
|
|
666
|
+
if (existing && existing.sha256 !== entry.sha256)
|
|
667
|
+
throw new Error(`Asset manifest group ${entry.rendererId} has conflicting owners.`);
|
|
668
|
+
if (!existing)
|
|
669
|
+
byRendererId.set(entry.rendererId, entry);
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
schemaVersion: 1,
|
|
673
|
+
rendererAssetManifests: [...byRendererId.values()]
|
|
674
|
+
.sort((left, right) => left.rendererId.localeCompare(right.rendererId))
|
|
675
|
+
.map(entry => entry.manifest),
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
const mergeFileViewerAssetManifestOwnerUnlocked = async (input, targetDir) => {
|
|
679
|
+
for (const path of [assetLedgerFilename, runtimeManifestFilename])
|
|
680
|
+
await assertNoSymlinkPath(targetDir, path);
|
|
681
|
+
const ledgerPath = resolve(targetDir, assetLedgerFilename);
|
|
682
|
+
const manifestPath = resolve(targetDir, runtimeManifestFilename);
|
|
683
|
+
const nextLedger = nextLedgerForOwner(await readFileViewerAssetLedger(targetDir), input);
|
|
684
|
+
const previousLedger = await readTextNoFollow(ledgerPath);
|
|
685
|
+
const previousManifest = await readTextNoFollow(manifestPath);
|
|
686
|
+
try {
|
|
687
|
+
await atomicWrite(targetDir, ledgerPath, `${JSON.stringify(nextLedger, null, 2)}\n`);
|
|
688
|
+
await atomicWrite(targetDir, manifestPath, `${JSON.stringify(runtimeManifestFromLedger(nextLedger), null, 2)}\n`);
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
if (previousLedger === null)
|
|
692
|
+
await rm(ledgerPath, { force: true });
|
|
693
|
+
else
|
|
694
|
+
await atomicWrite(targetDir, ledgerPath, previousLedger);
|
|
695
|
+
if (previousManifest === null)
|
|
696
|
+
await rm(manifestPath, { force: true });
|
|
697
|
+
else
|
|
698
|
+
await atomicWrite(targetDir, manifestPath, previousManifest);
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
return { ledgerPath, manifestPath, ledger: nextLedger };
|
|
702
|
+
};
|
|
703
|
+
export async function mergeFileViewerAssetManifestOwner(input) {
|
|
704
|
+
const targetDir = await prepareTargetDir(input.targetDir);
|
|
705
|
+
const lock = await acquireAssetLock(targetDir);
|
|
706
|
+
try {
|
|
707
|
+
return await mergeFileViewerAssetManifestOwnerUnlocked(input, targetDir);
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
await releaseAssetLock(lock);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const assertCleanTarget = (targetDir) => {
|
|
714
|
+
if (targetDir === resolve('/') || targetDir === resolve(process.cwd()) ||
|
|
715
|
+
targetDir === resolve(process.env.HOME || '/nonexistent') || basename(targetDir) !== 'file-viewer') {
|
|
716
|
+
throw new Error(`Refusing to clean unsafe target ${targetDir}.`);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
const restoreTextSnapshot = async (root, path, value) => {
|
|
720
|
+
if (value === null)
|
|
721
|
+
await rm(path, { force: true });
|
|
722
|
+
else
|
|
723
|
+
await atomicWrite(root, path, value);
|
|
724
|
+
};
|
|
725
|
+
export async function installFileViewerCapabilityAssetPack(metadata, options = {}) {
|
|
726
|
+
const requestedTargetDir = resolve(options.targetDir || process.env.FILE_VIEWER_PUBLIC_DIR || resolve(process.env.INIT_CWD || process.cwd(), 'public/file-viewer'));
|
|
727
|
+
if (options.clean && !options.confirmClean)
|
|
728
|
+
throw new Error('Full asset cleanup requires both --clean and --confirm.');
|
|
729
|
+
if (options.clean)
|
|
730
|
+
assertCleanTarget(requestedTargetDir);
|
|
731
|
+
const targetDir = await prepareTargetDir(requestedTargetDir);
|
|
732
|
+
const bundledRoot = resolve(metadata.packRoot, 'viewer');
|
|
733
|
+
const manifestFilename = metadata.manifestFilename || 'file-viewer-asset-pack.json';
|
|
734
|
+
const manifestPath = contained(bundledRoot, manifestFilename);
|
|
735
|
+
let manifestContent;
|
|
736
|
+
try {
|
|
737
|
+
manifestContent = await readNoFollow(manifestPath);
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
if (error.code === 'ENOENT') {
|
|
741
|
+
throw new Error(`Missing capability asset pack manifest: ${manifestPath}.`, { cause: error });
|
|
742
|
+
}
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
const manifest = JSON.parse(Buffer.from(manifestContent).toString('utf8'));
|
|
746
|
+
validateJsonValue(manifest, `Capability asset pack manifest for ${metadata.packageName}`);
|
|
747
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.rendererAssetManifests)) {
|
|
748
|
+
throw new Error(`Capability asset pack manifest is invalid for ${metadata.packageName}.`);
|
|
749
|
+
}
|
|
750
|
+
const seenManifestRendererIds = new Set();
|
|
751
|
+
manifest.rendererAssetManifests = manifest.rendererAssetManifests.map(item => {
|
|
752
|
+
const validated = validateRendererManifest(item, `Capability asset pack manifest for ${metadata.packageName}`);
|
|
753
|
+
if (seenManifestRendererIds.has(validated.rendererId)) {
|
|
754
|
+
throw new Error(`Capability asset pack ${metadata.packageName} repeats renderer ${validated.rendererId}.`);
|
|
755
|
+
}
|
|
756
|
+
seenManifestRendererIds.add(validated.rendererId);
|
|
757
|
+
return validated;
|
|
758
|
+
});
|
|
759
|
+
if (manifest.copyGroups && (manifest.copyGroups.some(group => typeof group !== 'string' || !rendererIdPattern.test(group)) ||
|
|
760
|
+
new Set(manifest.copyGroups).size !== manifest.copyGroups.length)) {
|
|
761
|
+
throw new Error(`Capability asset pack ${metadata.packageName} has invalid copyGroups.`);
|
|
762
|
+
}
|
|
763
|
+
if ((manifest.packageName && manifest.packageName !== metadata.packageName) ||
|
|
764
|
+
(manifest.packageVersion && manifest.packageVersion !== metadata.packageVersion)) {
|
|
765
|
+
throw new Error(`Capability asset pack metadata drifted for ${metadata.packageName}.`);
|
|
766
|
+
}
|
|
767
|
+
if (metadata.profile && manifest.profile !== metadata.profile) {
|
|
768
|
+
throw new Error(`Capability asset pack profile drifted for ${metadata.packageName}.`);
|
|
769
|
+
}
|
|
770
|
+
const operationRoot = await mkdtemp(resolve(tmpdir(), 'file-viewer-capability-assets-'));
|
|
771
|
+
const stagedRoot = resolve(operationRoot, 'staged');
|
|
772
|
+
const backupRoot = resolve(operationRoot, 'backup');
|
|
773
|
+
const touched = [];
|
|
774
|
+
let cleanMoved = false;
|
|
775
|
+
const metadataBefore = new Map();
|
|
776
|
+
const lock = await acquireAssetLock(targetDir);
|
|
777
|
+
try {
|
|
778
|
+
await listFiles(bundledRoot);
|
|
779
|
+
await cp(bundledRoot, stagedRoot, { recursive: true, force: true, dereference: false });
|
|
780
|
+
const stagedPaths = (await listFiles(stagedRoot)).filter(path => path !== manifestFilename);
|
|
781
|
+
const stagedFiles = await Promise.all(stagedPaths.map(path => inspectFile(stagedRoot, path)));
|
|
782
|
+
const receiptPath = resolve(targetDir, metadata.receiptFilename);
|
|
783
|
+
const ledgerPath = resolve(targetDir, assetLedgerFilename);
|
|
784
|
+
const runtimePath = resolve(targetDir, runtimeManifestFilename);
|
|
785
|
+
for (const path of [metadata.receiptFilename, assetLedgerFilename, runtimeManifestFilename])
|
|
786
|
+
await assertNoSymlinkPath(targetDir, path);
|
|
787
|
+
const previous = await readReceipt(receiptPath);
|
|
788
|
+
for (const path of [receiptPath, ledgerPath, runtimePath])
|
|
789
|
+
metadataBefore.set(path, await readTextNoFollow(path));
|
|
790
|
+
let files;
|
|
791
|
+
if (options.clean) {
|
|
792
|
+
await rm(contained(stagedRoot, manifestFilename), { force: true });
|
|
793
|
+
await atomicWrite(stagedRoot, resolve(stagedRoot, assetLockFilename), lock.content);
|
|
794
|
+
if (existsSync(targetDir)) {
|
|
795
|
+
await rename(targetDir, backupRoot);
|
|
796
|
+
cleanMoved = true;
|
|
797
|
+
}
|
|
798
|
+
await rename(stagedRoot, targetDir);
|
|
799
|
+
files = stagedFiles.map(file => ({ ...file, ownership: 'managed' }));
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
await mkdir(backupRoot, { recursive: true });
|
|
803
|
+
const ledger = await readFileViewerAssetLedger(targetDir);
|
|
804
|
+
const previousByPath = new Map(previous?.files.map(file => [file.path, file]) || []);
|
|
805
|
+
const ledgerByPath = new Map(ledger.paths.map(path => [path.path, path]));
|
|
806
|
+
const stagedByPath = new Set(stagedFiles.map(file => file.path));
|
|
807
|
+
const ownership = new Map();
|
|
808
|
+
const unchanged = new Set();
|
|
809
|
+
for (const file of stagedFiles) {
|
|
810
|
+
await assertNoSymlinkPath(targetDir, file.path);
|
|
811
|
+
const destination = contained(targetDir, file.path);
|
|
812
|
+
if (!existsSync(destination)) {
|
|
813
|
+
ownership.set(file.path, 'managed');
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const destinationHash = await hashFile(destination);
|
|
817
|
+
const old = previousByPath.get(file.path);
|
|
818
|
+
const ledgerPathEntry = ledgerByPath.get(file.path);
|
|
819
|
+
const isManaged = old?.ownership === 'managed' || ledgerPathEntry?.owners.some(owner => owner.ownership === 'managed');
|
|
820
|
+
if (isManaged) {
|
|
821
|
+
if ((old && destinationHash !== old.sha256) ||
|
|
822
|
+
(ledgerPathEntry && destinationHash !== ledgerPathEntry.sha256)) {
|
|
823
|
+
throw new Error(`Managed asset was modified outside File Viewer: ${file.path}.`);
|
|
824
|
+
}
|
|
825
|
+
const otherOwners = ledgerPathEntry?.owners.filter(owner => owner.packageName !== metadata.packageName) || [];
|
|
826
|
+
if (destinationHash !== file.sha256 && otherOwners.length) {
|
|
827
|
+
throw new Error(`Asset path ${file.path} is still required by ${otherOwners[0].packageName}.`);
|
|
828
|
+
}
|
|
829
|
+
ownership.set(file.path, 'managed');
|
|
830
|
+
if (destinationHash === file.sha256)
|
|
831
|
+
unchanged.add(file.path);
|
|
832
|
+
}
|
|
833
|
+
else if (destinationHash === file.sha256) {
|
|
834
|
+
ownership.set(file.path, 'shared');
|
|
835
|
+
unchanged.add(file.path);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
throw new Error(`Refusing to overwrite an unowned asset: ${file.path}.`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
for (const old of previous?.files || []) {
|
|
842
|
+
if (old.ownership !== 'managed' || stagedByPath.has(old.path))
|
|
843
|
+
continue;
|
|
844
|
+
await assertNoSymlinkPath(targetDir, old.path);
|
|
845
|
+
const destination = contained(targetDir, old.path);
|
|
846
|
+
if (existsSync(destination) && await hashFile(destination) !== old.sha256) {
|
|
847
|
+
throw new Error(`Stale managed asset was modified outside File Viewer: ${old.path}.`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
for (const file of stagedFiles) {
|
|
851
|
+
if (unchanged.has(file.path))
|
|
852
|
+
continue;
|
|
853
|
+
await assertNoSymlinkPath(targetDir, file.path);
|
|
854
|
+
const destination = contained(targetDir, file.path);
|
|
855
|
+
if (existsSync(destination)) {
|
|
856
|
+
const backup = contained(backupRoot, file.path);
|
|
857
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
858
|
+
await copyFileNoFollow(destination, backup);
|
|
859
|
+
}
|
|
860
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
861
|
+
await assertNoSymlinkPath(targetDir, file.path);
|
|
862
|
+
await copyFileNoFollow(contained(stagedRoot, file.path), destination);
|
|
863
|
+
touched.push(file.path);
|
|
864
|
+
}
|
|
865
|
+
for (const old of previous?.files || []) {
|
|
866
|
+
if (old.ownership !== 'managed' || stagedByPath.has(old.path))
|
|
867
|
+
continue;
|
|
868
|
+
if (ledgerByPath.get(old.path)?.owners.some(owner => owner.packageName !== metadata.packageName))
|
|
869
|
+
continue;
|
|
870
|
+
const destination = contained(targetDir, old.path);
|
|
871
|
+
if (!existsSync(destination))
|
|
872
|
+
continue;
|
|
873
|
+
const backup = contained(backupRoot, old.path);
|
|
874
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
875
|
+
await copyFileNoFollow(destination, backup);
|
|
876
|
+
await rm(destination, { force: true });
|
|
877
|
+
touched.push(old.path);
|
|
878
|
+
}
|
|
879
|
+
files = stagedFiles.map(file => ({ ...file, ownership: ownership.get(file.path) || 'managed' }));
|
|
880
|
+
}
|
|
881
|
+
const assetManifestSha256 = sha256(manifestContent);
|
|
882
|
+
const copyGroups = [...(manifest.copyGroups || manifest.rendererAssetManifests.map(item => item.rendererId))].sort();
|
|
883
|
+
const same = !options.clean && previous?.packageVersion === metadata.packageVersion &&
|
|
884
|
+
previous.assetManifestSha256 === assetManifestSha256 && JSON.stringify(previous.files) === JSON.stringify(files);
|
|
885
|
+
const receipt = {
|
|
886
|
+
schemaVersion: 1,
|
|
887
|
+
packageName: metadata.packageName,
|
|
888
|
+
packageVersion: metadata.packageVersion,
|
|
889
|
+
assetManifestSha256,
|
|
890
|
+
copyGroups,
|
|
891
|
+
...(metadata.profile ? { profile: metadata.profile } : {}),
|
|
892
|
+
...(manifest.profileManifestSha256 ? { profileManifestSha256: manifest.profileManifestSha256 } : {}),
|
|
893
|
+
installedAt: same ? previous.installedAt : new Date().toISOString(),
|
|
894
|
+
files,
|
|
895
|
+
};
|
|
896
|
+
if (!same || options.clean)
|
|
897
|
+
await atomicWrite(targetDir, resolve(targetDir, metadata.receiptFilename), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
898
|
+
await mergeFileViewerAssetManifestOwnerUnlocked({
|
|
899
|
+
targetDir,
|
|
900
|
+
ownerPackage: metadata.packageName,
|
|
901
|
+
ownerVersion: metadata.packageVersion,
|
|
902
|
+
receiptFilename: metadata.receiptFilename,
|
|
903
|
+
rendererAssetManifests: manifest.rendererAssetManifests,
|
|
904
|
+
files,
|
|
905
|
+
}, targetDir);
|
|
906
|
+
return {
|
|
907
|
+
targetDir,
|
|
908
|
+
receiptPath: resolve(targetDir, metadata.receiptFilename),
|
|
909
|
+
copyGroups,
|
|
910
|
+
changed: !same || Boolean(options.clean) || touched.length > 0,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
if (options.clean) {
|
|
915
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
916
|
+
if (cleanMoved && existsSync(backupRoot))
|
|
917
|
+
await rename(backupRoot, targetDir);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
for (const path of [...touched].reverse()) {
|
|
921
|
+
const destination = contained(targetDir, path);
|
|
922
|
+
const backup = contained(backupRoot, path);
|
|
923
|
+
if (existsSync(backup)) {
|
|
924
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
925
|
+
await copyFileNoFollow(backup, destination);
|
|
926
|
+
}
|
|
927
|
+
else
|
|
928
|
+
await rm(destination, { force: true });
|
|
929
|
+
}
|
|
930
|
+
for (const [path, value] of metadataBefore)
|
|
931
|
+
await restoreTextSnapshot(targetDir, path, value);
|
|
932
|
+
}
|
|
933
|
+
throw error;
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
try {
|
|
937
|
+
await releaseAssetLock(lock);
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
await rm(operationRoot, { recursive: true, force: true });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
export async function verifyFileViewerCapabilityAssetReceipt(targetDirInput, receiptFilename) {
|
|
945
|
+
const targetDir = await resolveExistingTargetDir(targetDirInput);
|
|
946
|
+
if (!targetDir)
|
|
947
|
+
return { ok: false, errors: [`Missing asset target ${resolve(targetDirInput)}.`], receipt: null };
|
|
948
|
+
await assertNoSymlinkPath(targetDir, receiptFilename);
|
|
949
|
+
const receipt = await readReceipt(resolve(targetDir, receiptFilename));
|
|
950
|
+
if (!receipt)
|
|
951
|
+
return { ok: false, errors: [`Missing ${receiptFilename}.`], receipt: null };
|
|
952
|
+
const errors = [];
|
|
953
|
+
for (const file of receipt.files) {
|
|
954
|
+
try {
|
|
955
|
+
await assertNoSymlinkPath(targetDir, file.path);
|
|
956
|
+
const inspected = await inspectFile(targetDir, file.path);
|
|
957
|
+
if (inspected.size !== file.size || inspected.sha256 !== file.sha256) {
|
|
958
|
+
errors.push(`Asset failed integrity verification: ${file.path}.`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
errors.push(`Missing or unsafe asset: ${file.path}.`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return { ok: errors.length === 0, errors, receipt };
|
|
966
|
+
}
|
|
967
|
+
export async function uninstallFileViewerCapabilityAssetPack(targetDirInput, ownerPackage, receiptFilename) {
|
|
968
|
+
const targetDir = await resolveExistingTargetDir(targetDirInput);
|
|
969
|
+
if (!targetDir)
|
|
970
|
+
return { changed: false, targetDir: resolve(targetDirInput), removedFiles: [] };
|
|
971
|
+
const lock = await acquireAssetLock(targetDir);
|
|
972
|
+
try {
|
|
973
|
+
for (const path of [receiptFilename, assetLedgerFilename, runtimeManifestFilename])
|
|
974
|
+
await assertNoSymlinkPath(targetDir, path);
|
|
975
|
+
const receiptPath = resolve(targetDir, receiptFilename);
|
|
976
|
+
const receipt = await readReceipt(receiptPath);
|
|
977
|
+
if (!receipt)
|
|
978
|
+
return { changed: false, targetDir, removedFiles: [] };
|
|
979
|
+
if (receipt.packageName !== ownerPackage)
|
|
980
|
+
throw new Error(`Receipt ${receiptFilename} belongs to ${receipt.packageName}.`);
|
|
981
|
+
const ledger = await readFileViewerAssetLedger(targetDir);
|
|
982
|
+
const removable = receipt.files.filter(file => file.ownership === 'managed' &&
|
|
983
|
+
!ledger.paths.find(item => item.path === file.path)?.owners.some(owner => owner.packageName !== ownerPackage));
|
|
984
|
+
for (const file of removable) {
|
|
985
|
+
await assertNoSymlinkPath(targetDir, file.path);
|
|
986
|
+
const path = contained(targetDir, file.path);
|
|
987
|
+
if (existsSync(path) && await hashFile(path) !== file.sha256) {
|
|
988
|
+
throw new Error(`Refusing to remove modified managed asset: ${file.path}.`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const operationRoot = await mkdtemp(resolve(tmpdir(), 'file-viewer-capability-remove-'));
|
|
992
|
+
const backupRoot = resolve(operationRoot, 'backup');
|
|
993
|
+
const metadataPaths = [receiptPath, resolve(targetDir, assetLedgerFilename), resolve(targetDir, runtimeManifestFilename)];
|
|
994
|
+
const metadataBefore = new Map();
|
|
995
|
+
for (const path of metadataPaths)
|
|
996
|
+
metadataBefore.set(path, await readTextNoFollow(path));
|
|
997
|
+
const removedFiles = [];
|
|
998
|
+
try {
|
|
999
|
+
for (const file of removable) {
|
|
1000
|
+
const path = contained(targetDir, file.path);
|
|
1001
|
+
if (!existsSync(path))
|
|
1002
|
+
continue;
|
|
1003
|
+
const backup = contained(backupRoot, file.path);
|
|
1004
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
1005
|
+
await copyFileNoFollow(path, backup);
|
|
1006
|
+
await rm(path, { force: true });
|
|
1007
|
+
removedFiles.push(file.path);
|
|
1008
|
+
}
|
|
1009
|
+
await rm(receiptPath, { force: true });
|
|
1010
|
+
await mergeFileViewerAssetManifestOwnerUnlocked({
|
|
1011
|
+
targetDir,
|
|
1012
|
+
ownerPackage,
|
|
1013
|
+
ownerVersion: receipt.packageVersion,
|
|
1014
|
+
receiptFilename,
|
|
1015
|
+
rendererAssetManifests: [],
|
|
1016
|
+
files: [],
|
|
1017
|
+
}, targetDir);
|
|
1018
|
+
return { changed: true, targetDir, removedFiles };
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
for (const path of removedFiles) {
|
|
1022
|
+
const destination = contained(targetDir, path);
|
|
1023
|
+
const backup = contained(backupRoot, path);
|
|
1024
|
+
if (existsSync(backup)) {
|
|
1025
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
1026
|
+
await copyFileNoFollow(backup, destination);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
for (const [path, value] of metadataBefore)
|
|
1030
|
+
await restoreTextSnapshot(targetDir, path, value);
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
await rm(operationRoot, { recursive: true, force: true });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
await releaseAssetLock(lock);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** Shared implementation for the stable bin shipped by each independent asset-owner package. */
|
|
1042
|
+
export async function runFileViewerAssetPackCli(packRootInput, args) {
|
|
1043
|
+
const packRoot = resolve(packRootInput);
|
|
1044
|
+
const packageJson = JSON.parse((await readNoFollow(resolve(packRoot, 'package.json'))).toString('utf8'));
|
|
1045
|
+
const config = JSON.parse((await readNoFollow(resolve(packRoot, 'file-viewer.asset-pack.json'))).toString('utf8'));
|
|
1046
|
+
if (config.packageName !== packageJson.name)
|
|
1047
|
+
throw new Error(`Asset pack config drifted for ${packageJson.name}.`);
|
|
1048
|
+
let targetDir;
|
|
1049
|
+
let clean = false;
|
|
1050
|
+
let confirmClean = false;
|
|
1051
|
+
for (const arg of args) {
|
|
1052
|
+
if (arg === '--clean')
|
|
1053
|
+
clean = true;
|
|
1054
|
+
else if (arg === '--confirm')
|
|
1055
|
+
confirmClean = true;
|
|
1056
|
+
else if (arg === '--help' || arg === '-h') {
|
|
1057
|
+
return { help: true, text: `${packageJson.name} [target-directory] [--clean --confirm]\n` };
|
|
1058
|
+
}
|
|
1059
|
+
else if (arg.startsWith('-'))
|
|
1060
|
+
throw new Error(`Unknown option ${arg}.`);
|
|
1061
|
+
else if (targetDir)
|
|
1062
|
+
throw new Error('Only one target directory is supported.');
|
|
1063
|
+
else
|
|
1064
|
+
targetDir = arg;
|
|
1065
|
+
}
|
|
1066
|
+
const result = await installFileViewerCapabilityAssetPack({
|
|
1067
|
+
packageName: packageJson.name,
|
|
1068
|
+
packageVersion: packageJson.version,
|
|
1069
|
+
packRoot,
|
|
1070
|
+
receiptFilename: config.receiptFilename,
|
|
1071
|
+
}, { targetDir, clean, confirmClean });
|
|
1072
|
+
return { help: false, result };
|
|
1073
|
+
}
|