@playcraft/cli 0.1.67 → 0.1.69
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/dist/asset-library/provenance.js +169 -0
- package/dist/commands/audio-program.js +82 -0
- package/dist/commands/audio.js +2 -0
- package/dist/commands/library-messages.js +27 -9
- package/dist/commands/library.js +62 -10
- package/dist/model-adaptation/project-template.js +56 -3
- package/dist/model-adaptation/project.js +15 -1
- package/dist/workspace-runtime/batch/freeze.js +15 -2
- package/dist/workspace-runtime/batch/git.js +16 -4
- package/dist/workspace-runtime/batch/publication.js +96 -0
- package/dist/workspace-runtime/batch/source-candidate.js +2 -1
- package/dist/workspace-runtime/batch/source-publish.js +26 -15
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
- package/dist/workspace-runtime/serve.js +50 -18
- package/dist/workspace-runtime/server/audio-assets.js +49 -3
- package/dist/workspace-runtime/server/dispatch.js +42 -9
- package/dist/workspace-runtime/server/http.js +12 -2
- package/dist/workspace-runtime/verify-artifact.js +2 -1
- package/dist/workspace-runtime/workspaces/archive.js +28 -3
- package/dist/workspace-runtime/workspaces/asset-library-import.js +60 -9
- package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
- package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
- package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
- package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
- package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
- package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
- package/dist/workspace-runtime/workspaces/git.js +7 -0
- package/dist/workspace-runtime/workspaces/registry.js +3 -5
- package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
- package/package.json +6 -3
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.playcraft/skills.lock.json +4 -4
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
|
@@ -44,6 +44,7 @@ import { acceptOperationCore, observeLevelEditorTransaction } from './accept-ope
|
|
|
44
44
|
import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
45
45
|
import { searchWorkspaceFiles } from '../workspaces/files-search.js';
|
|
46
46
|
import { serveWorkspaceArchive } from '../workspaces/archive.js';
|
|
47
|
+
import { verifyRuntimeAssetProvenance } from '../workspaces/asset-provenance-verifier.js';
|
|
47
48
|
import { handleWorkspaceTransfer } from './workspace-transfer.js';
|
|
48
49
|
import { handleWorkspaceStorage } from './workspace-storage.js';
|
|
49
50
|
import { serveSessionImage } from '../workspaces/session-image.js';
|
|
@@ -199,6 +200,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
199
200
|
levelEditorRetryVersion: 1,
|
|
200
201
|
remixReferencesVersion: 1,
|
|
201
202
|
batchSourceFreezeRevision: 2,
|
|
203
|
+
batchSourcePublicationVersion: 1,
|
|
202
204
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
203
205
|
...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
|
|
204
206
|
...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
|
|
@@ -685,8 +687,16 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
685
687
|
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/archive') {
|
|
686
688
|
if (writeDestroyed(state, response))
|
|
687
689
|
return;
|
|
688
|
-
const
|
|
689
|
-
await
|
|
690
|
+
const archiveWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
691
|
+
const root = await sessionWorkspaceRoot(state, archiveWorkspaceId, directoryName);
|
|
692
|
+
const expectedModelPaths = state.journal.listOperations().map(({ operation }) => operation).filter(operation => operation.kind === 'asset.library.import'
|
|
693
|
+
&& (state.config.workspaceMode !== 'multi' || operation.workspaceId === archiveWorkspaceId)
|
|
694
|
+
&& (operation.directoryName ?? 'basic') === directoryName)
|
|
695
|
+
.flatMap(operation => operation.result && 'bundleReceipt' in operation.result ? operation.result.bundleReceipt?.files ?? [] : [])
|
|
696
|
+
.map(file => file.path).filter(path => path.toLowerCase().endsWith('.glb'));
|
|
697
|
+
await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir],
|
|
698
|
+
expectedModelPaths: [...new Set(expectedModelPaths)],
|
|
699
|
+
verifyProvenance: provenance => verifyRuntimeAssetProvenance(state, provenance) });
|
|
690
700
|
return;
|
|
691
701
|
}
|
|
692
702
|
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/file-content') {
|
|
@@ -47,6 +47,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
47
47
|
const identity = await response.json();
|
|
48
48
|
assert.equal(identity.runtimeId, config.runtimeId);
|
|
49
49
|
assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
|
|
50
|
+
assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
|
|
50
51
|
}
|
|
51
52
|
finally {
|
|
52
53
|
await runtime?.close();
|
|
@@ -55,5 +56,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
55
56
|
}
|
|
56
57
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
57
58
|
await verifyRuntimeArtifact(process.argv[2] ?? '');
|
|
58
|
-
console.log('Runtime artifact verified: batchSourceFreezeRevision=2');
|
|
59
|
+
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
|
|
59
60
|
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { lstat, open, opendir, realpath } from 'node:fs/promises';
|
|
3
4
|
import { join, relative, sep } from 'node:path';
|
|
4
5
|
import { Readable, Transform } from 'node:stream';
|
|
5
6
|
import { pipeline } from 'node:stream/promises';
|
|
6
7
|
import { createDeflateRaw } from 'node:zlib';
|
|
7
8
|
import { isCredentialFile } from './git.js';
|
|
8
|
-
|
|
9
|
+
import { verifyWorkspaceAssetProvenance, WORKSPACE_ASSET_PROVENANCE_PATH } from '../../asset-library/provenance.js';
|
|
10
|
+
import { workspaceAssetProvenanceDocumentSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
11
|
+
const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft-temp', '.playcraft.json', '.playcraft-workspace.json']);
|
|
9
12
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
10
13
|
const MAX_ENTRIES = 50_000;
|
|
14
|
+
const PUBLIC_ARCHIVE_ERRORS = new Set(['workspace_archive_changed', 'workspace_archive_too_large', 'workspace_archive_invalid_path',
|
|
15
|
+
'workspace_archive_failed', 'asset_provenance_authority_required', 'asset_provenance_authority_unavailable',
|
|
16
|
+
'asset_provenance_authority_mismatch', 'asset_provenance_authority_response_bound', 'asset_provenance_model_changed',
|
|
17
|
+
'asset_provenance_license_changed', 'asset_provenance_record_missing', 'asset_provenance_license_path_mismatch',
|
|
18
|
+
'asset_provenance_too_large', 'asset_provenance_changed', 'asset_provenance_notice_conflict', 'asset_provenance_expected_path_invalid']);
|
|
11
19
|
const UTF8_DESCRIPTOR_FLAGS = 0x0808;
|
|
12
20
|
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
13
21
|
let crc = value;
|
|
@@ -18,6 +26,13 @@ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
|
18
26
|
/** Exports working files, including ignored deliverables, without following symbolic links. */
|
|
19
27
|
export async function createWorkspaceArchive(root, options = {}) {
|
|
20
28
|
const rootReal = await realpath(root);
|
|
29
|
+
const provenance = await verifyWorkspaceAssetProvenance(rootReal, { verify: options.verifyProvenance,
|
|
30
|
+
expectedModelPaths: options.expectedModelPaths });
|
|
31
|
+
const expectedHashes = new Map();
|
|
32
|
+
for (const asset of provenance?.assets ?? []) {
|
|
33
|
+
expectedHashes.set(asset.path, asset.provenance.modelSha256);
|
|
34
|
+
expectedHashes.set(asset.licensePath, createHash('sha256').update(asset.provenance.licenseText).digest('hex'));
|
|
35
|
+
}
|
|
21
36
|
const excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => path)));
|
|
22
37
|
const entries = [];
|
|
23
38
|
let bytes = 0;
|
|
@@ -52,7 +67,8 @@ export async function createWorkspaceArchive(root, options = {}) {
|
|
|
52
67
|
bytes += info.size;
|
|
53
68
|
if (bytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES) || entries.length >= (options.maxEntries ?? MAX_ENTRIES))
|
|
54
69
|
throw new Error('workspace_archive_too_large');
|
|
55
|
-
entries.push({ path, target, info
|
|
70
|
+
entries.push({ path, target, info, expectedHash: expectedHashes.get(path),
|
|
71
|
+
...(path === WORKSPACE_ASSET_PROVENANCE_PATH && provenance ? { expectedProvenance: JSON.stringify(provenance) } : {}) });
|
|
56
72
|
}
|
|
57
73
|
};
|
|
58
74
|
await walk(rootReal);
|
|
@@ -89,6 +105,8 @@ async function* zipFiles(root, entries, signal) {
|
|
|
89
105
|
let crc = 0xffffffff;
|
|
90
106
|
let size = 0;
|
|
91
107
|
let compressedSize = 0;
|
|
108
|
+
const hash = entry.expectedHash ? createHash('sha256') : undefined;
|
|
109
|
+
const sourceChunks = [];
|
|
92
110
|
const checksum = new Transform({ transform(chunk, _encoding, done) {
|
|
93
111
|
size += chunk.length;
|
|
94
112
|
if (size > info.size) {
|
|
@@ -97,6 +115,9 @@ async function* zipFiles(root, entries, signal) {
|
|
|
97
115
|
}
|
|
98
116
|
for (const byte of chunk)
|
|
99
117
|
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
118
|
+
hash?.update(chunk);
|
|
119
|
+
if (entry.expectedProvenance)
|
|
120
|
+
sourceChunks.push(chunk);
|
|
100
121
|
done(null, chunk);
|
|
101
122
|
} });
|
|
102
123
|
const compressed = createDeflateRaw();
|
|
@@ -118,6 +139,10 @@ async function* zipFiles(root, entries, signal) {
|
|
|
118
139
|
}
|
|
119
140
|
if (size !== info.size)
|
|
120
141
|
throw new Error('workspace_archive_changed');
|
|
142
|
+
if (hash && hash.digest('hex') !== entry.expectedHash)
|
|
143
|
+
throw new Error('asset_provenance_model_changed');
|
|
144
|
+
if (entry.expectedProvenance && JSON.stringify(workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(Buffer.concat(sourceChunks).toString('utf8')))) !== entry.expectedProvenance)
|
|
145
|
+
throw new Error('asset_provenance_changed');
|
|
121
146
|
const after = await handle.stat();
|
|
122
147
|
if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
|
|
123
148
|
throw new Error('workspace_archive_changed');
|
|
@@ -188,7 +213,7 @@ export async function serveWorkspaceArchive(root, request, response, options = {
|
|
|
188
213
|
else if (!controller.signal.aborted) {
|
|
189
214
|
const reason = error instanceof Error ? error.message : 'workspace_archive_failed';
|
|
190
215
|
response.writeHead(reason === 'workspace_archive_too_large' ? 413 : 409, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
|
|
191
|
-
response.end(JSON.stringify({ error:
|
|
216
|
+
response.end(JSON.stringify({ error: PUBLIC_ARCHIVE_ERRORS.has(reason) ? reason : 'workspace_archive_failed' }));
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
finally {
|
|
@@ -6,6 +6,9 @@ import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES
|
|
|
6
6
|
import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
|
|
7
7
|
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
8
8
|
import { ensureSharedDirectory } from './shared-write.js';
|
|
9
|
+
import { publishLibraryModels } from '../../asset-library/provenance.js';
|
|
10
|
+
import { isEditableAudioLibraryAsset, prepareAudioLibraryImport } from './audio-assets/library-import.js';
|
|
11
|
+
import { readAudioArtifactPackage } from './audio-assets/artifact-package.js';
|
|
9
12
|
const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
|
|
10
13
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
11
14
|
const missing = (error) => error?.code === 'ENOENT';
|
|
@@ -158,6 +161,9 @@ async function readTransaction(root, transaction) {
|
|
|
158
161
|
|| !/^[a-f0-9]{64}$/.test(value.incomingHash))
|
|
159
162
|
throw new AssetLibraryImportError('invalid_library_import');
|
|
160
163
|
validPath(value.target);
|
|
164
|
+
if (value.audioPackagePaths && (value.audioPackagePaths.length !== 5 || new Set(value.audioPackagePaths).size !== 5
|
|
165
|
+
|| value.audioPackagePaths.some(path => !/^dist\/audio\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}\/(?:sound\.(?:pcaudio|pcscore)\.json|audition\.wav|runtime\.mjs|sound\.mjs|manifest\.json)$/.test(path))))
|
|
166
|
+
throw new AssetLibraryImportError('invalid_library_import');
|
|
161
167
|
if ((value.operationId !== undefined || value.assetId !== undefined || value.paths !== undefined)
|
|
162
168
|
&& (typeof value.operationId !== 'string' || !value.operationId || value.operationId.length > 128
|
|
163
169
|
|| typeof value.assetId !== 'string' || !value.assetId || value.assetId.length > 128
|
|
@@ -203,8 +209,17 @@ export async function recoverInterruptedLibraryImports(root) {
|
|
|
203
209
|
throw new AssetLibraryImportError('invalid_library_import_path');
|
|
204
210
|
if (current?.isDirectory() && !current.isSymbolicLink() && !incoming
|
|
205
211
|
&& await directorySnapshot(target) === manifest.incomingHash) {
|
|
212
|
+
if (manifest.audioPackagePaths) {
|
|
213
|
+
const primary = manifest.audioPackagePaths.find(path => /\/sound\.(?:pcaudio|pcscore)\.json$/.test(path));
|
|
214
|
+
if (!primary)
|
|
215
|
+
throw new AssetLibraryImportError('invalid_library_import');
|
|
216
|
+
const packaged = await readAudioArtifactPackage(rootReal, primary);
|
|
217
|
+
if (!packaged.sourcePath.startsWith(`${manifest.target}/`) || packaged.files.size !== 5
|
|
218
|
+
|| [...packaged.files.keys()].some(path => !manifest.audioPackagePaths.includes(path)))
|
|
219
|
+
throw new AssetLibraryImportError('invalid_library_import');
|
|
220
|
+
}
|
|
206
221
|
if (manifest.operationId && manifest.assetId && manifest.paths)
|
|
207
|
-
result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: manifest.paths });
|
|
222
|
+
result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: [...manifest.paths, ...(manifest.audioPackagePaths ?? [])] });
|
|
208
223
|
await removeTransaction(rootReal, transaction);
|
|
209
224
|
result.recovered.push(manifest.target);
|
|
210
225
|
continue;
|
|
@@ -236,7 +251,7 @@ export async function recoverInterruptedLibraryImports(root) {
|
|
|
236
251
|
}
|
|
237
252
|
return result;
|
|
238
253
|
}
|
|
239
|
-
async function publishBundle(root, target, asset, replace, assertCurrent, operationId) {
|
|
254
|
+
async function publishBundle(root, target, asset, replace, assertCurrent, operationId, audioPackagePaths) {
|
|
240
255
|
const parent = dirname(target) === '.' ? '' : dirname(target);
|
|
241
256
|
await createPublicDirectory(root, parent);
|
|
242
257
|
const previous = await statTarget(root, target);
|
|
@@ -256,6 +271,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
|
|
|
256
271
|
await writeWorkspaceBinaryFile(staged, { path: file.path, contentBase64: file.content, expectedContentHash: null });
|
|
257
272
|
}
|
|
258
273
|
const manifest = { version: 1, target, phase: 'prepared', originalHash: originalHash ?? null,
|
|
274
|
+
...(audioPackagePaths ? { audioPackagePaths } : {}),
|
|
259
275
|
incomingHash: await directorySnapshot(staged), ...(operationId ? { operationId, assetId: asset.assetId,
|
|
260
276
|
paths: asset.files.map(file => `${target}/${file.path}`) } : {}) };
|
|
261
277
|
await saveTransaction(root, transaction, manifest);
|
|
@@ -326,6 +342,8 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
326
342
|
await createPublicDirectory(rootReal, payload.targetDirectory);
|
|
327
343
|
const result = { paths: [], skippedPaths: [], failures: [...transport.failures] };
|
|
328
344
|
const processed = new Set(transport.failures.map(failure => failure.assetId));
|
|
345
|
+
const curated = [];
|
|
346
|
+
const plannedTargets = new Set();
|
|
329
347
|
const progress = () => options.onProgress?.({ ...result, failures: [...result.failures,
|
|
330
348
|
...payload.assetIds.filter(id => !processed.has(id)).map(assetId => ({ assetId, error: 'asset_library_import_interrupted' }))] });
|
|
331
349
|
await progress();
|
|
@@ -346,7 +364,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
346
364
|
let name = originalName;
|
|
347
365
|
let target = validPath([payload.targetDirectory, name].filter(Boolean).join('/'));
|
|
348
366
|
let existing = await statTarget(rootReal, target);
|
|
349
|
-
if (existing && payload.collision === 'skip') {
|
|
367
|
+
if ((existing || plannedTargets.has(target)) && payload.collision === 'skip') {
|
|
350
368
|
result.skippedPaths.push(target);
|
|
351
369
|
processed.add(assetId);
|
|
352
370
|
await progress();
|
|
@@ -355,7 +373,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
355
373
|
if (payload.collision === 'rename') {
|
|
356
374
|
const extension = asset.kind === 'bundle' ? '' : extname(originalName);
|
|
357
375
|
const stem = originalName.slice(0, originalName.length - extension.length);
|
|
358
|
-
for (let suffix = 1; existing; suffix++) {
|
|
376
|
+
for (let suffix = 1; existing || plannedTargets.has(target); suffix++) {
|
|
359
377
|
if (suffix > 10_000)
|
|
360
378
|
throw new AssetLibraryImportError('content_conflict');
|
|
361
379
|
name = `${stem} (${suffix})${extension}`;
|
|
@@ -363,10 +381,22 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
363
381
|
existing = await statTarget(rootReal, target);
|
|
364
382
|
}
|
|
365
383
|
}
|
|
384
|
+
if (asset.provenance) {
|
|
385
|
+
const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
|
|
386
|
+
curated.push({ assetId, path: target, bytes: Buffer.from(asset.files[0].content, 'base64'),
|
|
387
|
+
provenance: asset.provenance, expectedContentHash: observed?.contentHash ?? null });
|
|
388
|
+
plannedTargets.add(target);
|
|
389
|
+
await progress();
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
366
392
|
if (asset.kind === 'bundle') {
|
|
367
|
-
const
|
|
368
|
-
|
|
393
|
+
const audio = isEditableAudioLibraryAsset(asset) ? await prepareAudioLibraryImport(rootReal, target, asset, assertCurrent) : undefined;
|
|
394
|
+
const incoming = audio?.asset ?? asset;
|
|
395
|
+
const paths = incoming.files.map(file => validPath(`${target}/${file.path}`));
|
|
396
|
+
publishedTransaction = await publishBundle(rootReal, target, incoming, payload.collision === 'replace', assertCurrent, options.operationId, audio?.packagePaths);
|
|
369
397
|
result.paths.push(...paths);
|
|
398
|
+
if (audio)
|
|
399
|
+
result.paths.push(...audio.packagePaths);
|
|
370
400
|
}
|
|
371
401
|
else {
|
|
372
402
|
const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
|
|
@@ -377,10 +407,13 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
377
407
|
}
|
|
378
408
|
}
|
|
379
409
|
catch (error) {
|
|
410
|
+
if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
|
|
411
|
+
throw error;
|
|
380
412
|
const code = error instanceof AssetLibraryImportError ? error.message
|
|
381
|
-
: error
|
|
382
|
-
:
|
|
383
|
-
: '
|
|
413
|
+
: error instanceof Error && ['invalid_audio_library_package', 'audio_library_engine_unavailable', 'audio_library_source_unverified', 'audio_representation_conflict'].includes(error.message) ? error.message
|
|
414
|
+
: error?.code === 'content_conflict' ? 'content_conflict'
|
|
415
|
+
: ['EACCES', 'EPERM'].includes(String(error?.code)) ? 'workspace_permission_denied'
|
|
416
|
+
: 'asset_library_import_failed';
|
|
384
417
|
result.failures.push({ assetId, error: code });
|
|
385
418
|
}
|
|
386
419
|
processed.add(assetId);
|
|
@@ -388,5 +421,23 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
388
421
|
if (publishedTransaction)
|
|
389
422
|
await removeTransaction(rootReal, publishedTransaction).catch(() => undefined);
|
|
390
423
|
}
|
|
424
|
+
if (curated.length) {
|
|
425
|
+
try {
|
|
426
|
+
const receipt = await publishLibraryModels(rootReal, curated, { operationId: options.operationId,
|
|
427
|
+
scope: options.scope, assertCurrent });
|
|
428
|
+
result.bundleReceipt = receipt;
|
|
429
|
+
result.paths.push(...receipt.files.map(file => file.path));
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
|
|
433
|
+
throw error;
|
|
434
|
+
const code = error.code ?? (error instanceof Error ? error.message : 'asset_library_import_failed');
|
|
435
|
+
for (const model of curated)
|
|
436
|
+
result.failures.push({ assetId: model.assetId, error: code.slice(0, 256) });
|
|
437
|
+
}
|
|
438
|
+
for (const model of curated)
|
|
439
|
+
processed.add(model.assetId);
|
|
440
|
+
await progress();
|
|
441
|
+
}
|
|
391
442
|
return workspaceAssetLibraryImportResultSchema.parse(result);
|
|
392
443
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { assetLibraryModelProvenanceSchema, canonicalAssetLibraryModelProvenance } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { businessEndpoint } from '../../utils/environment-endpoints.js';
|
|
5
|
+
import { ensureCliCredentials } from './cli-credentials.js';
|
|
6
|
+
/** Compare the portable source record with the current user's immutable server revision. */
|
|
7
|
+
export async function verifyRuntimeAssetProvenance(state, provenance) {
|
|
8
|
+
await ensureCliCredentials(state);
|
|
9
|
+
const credential = JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8'));
|
|
10
|
+
if (typeof credential.apiUrl !== 'string' || typeof credential.token !== 'string' || !credential.token)
|
|
11
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
12
|
+
const url = businessEndpoint(credential.apiUrl, `/api/agent/tools/asset-library/${encodeURIComponent(provenance.assetId)}`);
|
|
13
|
+
url.searchParams.set('revisionId', provenance.revisionId);
|
|
14
|
+
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
15
|
+
headers: { 'X-Sandbox-Token': credential.token, 'playcraft-env': state.config.environment } });
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
await response.body?.cancel();
|
|
18
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
19
|
+
}
|
|
20
|
+
const maximum = 256 * 1024, chunks = [];
|
|
21
|
+
let length = 0;
|
|
22
|
+
if (!response.body)
|
|
23
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
24
|
+
for await (const chunk of response.body) {
|
|
25
|
+
length += chunk.byteLength;
|
|
26
|
+
if (length > maximum) {
|
|
27
|
+
await response.body.cancel().catch(() => undefined);
|
|
28
|
+
throw new Error('asset_provenance_authority_response_bound');
|
|
29
|
+
}
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
33
|
+
const authoritative = assetLibraryModelProvenanceSchema.parse(result.provenance);
|
|
34
|
+
if (canonicalAssetLibraryModelProvenance(authoritative) !== canonicalAssetLibraryModelProvenance(provenance))
|
|
35
|
+
throw new Error('asset_provenance_authority_mismatch');
|
|
36
|
+
}
|
|
@@ -4,6 +4,7 @@ import { canonicalAudioJson } from '@playcraft/common/cloud-workspace/runtime';
|
|
|
4
4
|
import { readAudioArtifactPackage } from './artifact-package.js';
|
|
5
5
|
import { audioError, audioProjectPath, digest, readAudioSource } from './storage.js';
|
|
6
6
|
import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
|
|
7
|
+
import { readAudioProgramBuildInputs } from './program-build-inputs.js';
|
|
7
8
|
const hashPart = /^[a-f0-9]{64}$/;
|
|
8
9
|
const MAX_PACKAGES = 256;
|
|
9
10
|
const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
|
|
@@ -65,12 +66,15 @@ export async function readAudioBuildInputs(root, destination) {
|
|
|
65
66
|
}
|
|
66
67
|
packages.push({ primaryPath, sourcePath: bundle.sourcePath, sourceContentHash: sourceText === undefined ? null : digest(sourceText), files });
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
+
const programs = await readAudioProgramBuildInputs(root, destination);
|
|
70
|
+
// Existing projects retain their original snapshot identity when no state programs are present.
|
|
71
|
+
return { version: 1, inputHash: digest(canonicalAudioJson(programs.packages.length ? { packages, programs } : packages)), packages,
|
|
72
|
+
...(programs.packages.length ? { programs } : {}) };
|
|
69
73
|
}
|
|
70
74
|
export async function verifyAudioBuildInputs(root, snapshot) {
|
|
71
75
|
if ((await readAudioBuildInputs(root)).inputHash !== snapshot.inputHash)
|
|
72
76
|
throw audioError('audio_build_input_changed');
|
|
73
77
|
}
|
|
74
78
|
export function markAudioBuildInputs(bytes, snapshot) {
|
|
75
|
-
return snapshot.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
|
|
79
|
+
return snapshot.packages.length || snapshot.programs?.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
|
|
76
80
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readdir, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { canonicalAudioJson, audioRepresentationSetHash, inspectAudioLibrarySnapshot, audioLibraryOriginSchema, audioLibraryPayloadHash } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { audioRendererScript } from './renderer.js';
|
|
6
|
+
import { readAudioArtifactPackage } from './artifact-package.js';
|
|
7
|
+
import { audioProjectPath, audioError, digest } from './storage.js';
|
|
8
|
+
import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
|
|
9
|
+
export const isEditableAudioLibraryAsset = (asset) => {
|
|
10
|
+
if (asset.files.some(file => /(?:^|\/)sound\.(?:pcaudio|pcscore)\.json$/.test(file.path)))
|
|
11
|
+
return true;
|
|
12
|
+
try {
|
|
13
|
+
const file = asset.files.find(file => file.path === 'manifest.json');
|
|
14
|
+
const value = file && JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
|
|
15
|
+
return Boolean(value && (value.sourcePath !== undefined || value.representationSetHash !== undefined));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function inspectAsset(asset, requirePlatformProof = true) {
|
|
22
|
+
if (requirePlatformProof && !asset.audioSource)
|
|
23
|
+
throw audioError('audio_library_source_unverified', 409);
|
|
24
|
+
try {
|
|
25
|
+
const manifestFile = asset.files.find(file => file.path === 'manifest.json');
|
|
26
|
+
if (!manifestFile)
|
|
27
|
+
throw new Error();
|
|
28
|
+
const manifest = JSON.parse(Buffer.from(manifestFile.content, 'base64').toString('utf8'));
|
|
29
|
+
const checked = inspectAudioLibrarySnapshot({ version: 1, target: asset.audioSource?.source.target ?? { kind: 'workspace-file', path: manifest.sourcePath },
|
|
30
|
+
revision: asset.audioSource?.source.revision ?? 1, sourcePath: manifest.sourcePath,
|
|
31
|
+
files: asset.files.map(file => ({ path: file.path, mimeType: file.mimeType, content: file.content,
|
|
32
|
+
contentHash: file.sha256, size: Buffer.from(file.content, 'base64').length })) });
|
|
33
|
+
if (asset.audioSource && audioLibraryPayloadHash(checked.source) !== audioLibraryPayloadHash(asset.audioSource.source))
|
|
34
|
+
throw new Error();
|
|
35
|
+
return checked;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw audioError('invalid_audio_library_package', 400);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function trustedEngine(checked) {
|
|
42
|
+
const engine = await audioRendererScript(checked.document.format === 'playcraft.score' ? 'score' : 'synth-sfx');
|
|
43
|
+
const suffix = checked.document.format === 'playcraft.score' ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
|
|
44
|
+
: '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n';
|
|
45
|
+
if (checked.source.rendererBuildHash !== engine.rendererBuildHash || Buffer.from(checked.files.get('runtime.mjs')).toString('utf8') !== engine.script + suffix)
|
|
46
|
+
throw audioError('audio_library_engine_unavailable', 409);
|
|
47
|
+
}
|
|
48
|
+
/** Verify against platform engine bytes before publishing any executable member. */
|
|
49
|
+
export async function prepareAudioLibraryImport(root, target, asset, assertCurrent) {
|
|
50
|
+
const checked = inspectAsset(asset);
|
|
51
|
+
await trustedEngine(checked);
|
|
52
|
+
await assertCurrent();
|
|
53
|
+
const sourcePath = `${target}/${checked.source.entryPath}`;
|
|
54
|
+
await audioProjectPath(root, sourcePath);
|
|
55
|
+
const { representationSetHash: _previous, ...oldPayload } = checked.manifest;
|
|
56
|
+
const payload = { ...oldPayload, sourcePath }, representationSetHash = audioRepresentationSetHash(payload);
|
|
57
|
+
const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash })}\n`);
|
|
58
|
+
const directory = `dist/audio/${digest(sourcePath)}/${checked.source.sourceContentHash}/${representationSetHash}`;
|
|
59
|
+
const members = new Map([...checked.files].map(([path, bytes]) => [path, Buffer.from(bytes)]));
|
|
60
|
+
members.set('manifest.json', manifest);
|
|
61
|
+
const destination = dirname(await audioProjectPath(root, `${directory}/manifest.json`));
|
|
62
|
+
await ensureSharedDirectory(root, dirname(destination));
|
|
63
|
+
const staging = `${destination}.${randomUUID()}.tmp`;
|
|
64
|
+
await ensureSharedDirectory(root, staging);
|
|
65
|
+
try {
|
|
66
|
+
for (const [path, bytes] of members)
|
|
67
|
+
await writeSharedFile(root, join(staging, path), bytes);
|
|
68
|
+
await assertCurrent();
|
|
69
|
+
const existing = await lstat(destination).catch(error => { if (error.code === 'ENOENT')
|
|
70
|
+
return undefined; throw error; });
|
|
71
|
+
if (existing) {
|
|
72
|
+
if (!existing.isDirectory() || existing.isSymbolicLink() || (await readdir(destination)).sort().join(',') !== [...members.keys()].sort().join(','))
|
|
73
|
+
throw audioError('audio_representation_conflict');
|
|
74
|
+
for (const [path, bytes] of members)
|
|
75
|
+
if (digest(await readFile(await audioProjectPath(root, `${directory}/${path}`))) !== digest(bytes))
|
|
76
|
+
throw audioError('audio_representation_conflict');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
await rename(staging, destination);
|
|
80
|
+
const parent = await open(dirname(destination), 'r');
|
|
81
|
+
try {
|
|
82
|
+
await parent.sync();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await parent.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(staging, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
const origin = { assetId: asset.assetId, contentHash: asset.contentHash, libraryPath: asset.libraryPath,
|
|
93
|
+
sourcePath: checked.source.sourcePath, sourceContentHash: checked.source.sourceContentHash, representationSetHash: checked.source.representationSetHash };
|
|
94
|
+
const metadata = Buffer.from(`${canonicalAudioJson({ version: 1, sourcePath, sourceContentHash: checked.source.sourceContentHash,
|
|
95
|
+
primaryPath: `${directory}/${checked.source.entryPath}`, representationSetHash, origin })}\n`);
|
|
96
|
+
const files = [{ path: checked.source.entryPath, bytes: members.get(checked.source.entryPath), mimeType: 'application/json' },
|
|
97
|
+
{ path: 'audio-library.json', bytes: metadata, mimeType: 'application/json' }].map(({ bytes, ...file }) => ({ ...file, content: bytes.toString('base64'), sha256: digest(bytes) }));
|
|
98
|
+
const portable = { ...asset, files,
|
|
99
|
+
contentHash: digest(JSON.stringify(files.map(file => [file.path, file.sha256]).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))) };
|
|
100
|
+
return { asset: portable, packagePaths: [...members.keys()].map(path => `${directory}/${path}`) };
|
|
101
|
+
}
|
|
102
|
+
/** Recover verified import ancestry; reuse its audition only while the current source still matches. */
|
|
103
|
+
export async function readImportedAudioLibrarySource(root, sourcePath, contentHash) {
|
|
104
|
+
const directory = dirname(sourcePath), sidecar = await audioProjectPath(root, `${directory === '.' ? '' : directory + '/'}audio-library.json`);
|
|
105
|
+
const stat = await lstat(sidecar).catch(error => { if (error.code === 'ENOENT')
|
|
106
|
+
return undefined; throw error; });
|
|
107
|
+
if (!stat)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 65_536)
|
|
110
|
+
throw audioError('audio_library_dependency_invalid', 409);
|
|
111
|
+
const metadata = JSON.parse(await readFile(sidecar, 'utf8'));
|
|
112
|
+
if (metadata.sourcePath !== sourcePath)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (metadata.version !== 1 || typeof metadata.primaryPath !== 'string')
|
|
115
|
+
throw audioError('audio_library_dependency_invalid', 409);
|
|
116
|
+
try {
|
|
117
|
+
const packaged = await readAudioArtifactPackage(root, metadata.primaryPath);
|
|
118
|
+
const files = [...packaged.files].map(([path, bytes]) => ({ path: path.slice(path.lastIndexOf('/') + 1), content: bytes.toString('base64'), sha256: digest(bytes),
|
|
119
|
+
mimeType: path.endsWith('.mjs') ? 'text/javascript' : path.endsWith('.wav') ? 'audio/wav' : 'application/json' }));
|
|
120
|
+
const asset = { assetId: 'verified-local', name: 'sound', libraryPath: '', visibility: 'team', kind: 'bundle', contentHash: ''.padStart(64, '0'), files };
|
|
121
|
+
const checked = inspectAsset(asset, false);
|
|
122
|
+
await trustedEngine(checked);
|
|
123
|
+
if (checked.source.sourcePath !== sourcePath || checked.source.sourceContentHash !== metadata.sourceContentHash || checked.source.representationSetHash !== metadata.representationSetHash)
|
|
124
|
+
throw new Error();
|
|
125
|
+
const origin = audioLibraryOriginSchema.parse(metadata.origin), { representationSetHash: _relocated, ...payload } = checked.manifest;
|
|
126
|
+
if (origin.sourceContentHash !== checked.source.sourceContentHash
|
|
127
|
+
|| audioRepresentationSetHash({ ...payload, sourcePath: origin.sourcePath }) !== origin.representationSetHash)
|
|
128
|
+
throw new Error();
|
|
129
|
+
const wav = Buffer.from(checked.files.get('audition.wav')), frames = (wav.length - 44) / 2;
|
|
130
|
+
let peak = 0, sum = 0;
|
|
131
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
132
|
+
const value = wav.readInt16LE(44 + frame * 2) / 32768;
|
|
133
|
+
peak = Math.max(peak, Math.abs(value));
|
|
134
|
+
sum += value * value;
|
|
135
|
+
}
|
|
136
|
+
if (peak > .9801)
|
|
137
|
+
throw new Error();
|
|
138
|
+
const current = { wav, audition: { mimeType: 'audio/wav', size: wav.length, contentHash: digest(wav), pcmContentHash: checked.manifest.pcmContentHash,
|
|
139
|
+
rendererBuildHash: checked.source.rendererBuildHash, chromiumVersion: checked.manifest.chromiumVersion, renderKey: checked.manifest.renderKey,
|
|
140
|
+
metrics: { peak, rms: Math.sqrt(sum / frames), frames, sampleRate: 44100, channels: 1, silent: peak === 0 } },
|
|
141
|
+
representation: { sourceContentHash: checked.source.sourceContentHash, rendererBuildHash: checked.source.rendererBuildHash, representationSetHash: checked.source.representationSetHash,
|
|
142
|
+
modulePath: `${dirname(metadata.primaryPath)}/sound.mjs`, manifestPath: `${dirname(metadata.primaryPath)}/manifest.json`,
|
|
143
|
+
files: [...packaged.files].map(([path, bytes]) => ({ path, size: bytes.length, contentHash: digest(bytes) })) } };
|
|
144
|
+
return { origin, current: checked.source.sourceContentHash === contentHash ? current : undefined };
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
throw audioError(error?.message === 'audio_library_engine_unavailable' ? 'audio_library_engine_unavailable' : 'audio_library_dependency_invalid', 409);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonicalAudioJson, audioRepresentationSetHash, audioLibraryModule, inspectAudioLibrarySnapshot } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { audioRendererScript } from './renderer.js';
|
|
3
|
+
import { audioError, digest } from './storage.js';
|
|
4
|
+
/** Package a verified audition and its fixed engine; saving to the library does not adopt the candidate. */
|
|
5
|
+
export async function createAudioLibrarySnapshot(input) {
|
|
6
|
+
const score = input.document.format === 'playcraft.score', engine = await audioRendererScript(score ? 'score' : 'synth-sfx');
|
|
7
|
+
if (engine.rendererBuildHash !== input.audition.rendererBuildHash)
|
|
8
|
+
throw audioError('engine_build_mismatch', 503);
|
|
9
|
+
const runtime = engine.script + (score ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
|
|
10
|
+
: '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n');
|
|
11
|
+
const members = [
|
|
12
|
+
{ path: score ? 'sound.pcscore.json' : 'sound.pcaudio.json', mimeType: 'application/json', role: 'editable', bytes: Buffer.from(input.sourceText) },
|
|
13
|
+
{ path: 'audition.wav', mimeType: 'audio/wav', role: 'audition', bytes: Buffer.from(input.wav) },
|
|
14
|
+
{ path: 'runtime.mjs', mimeType: 'text/javascript', role: 'runtime', bytes: Buffer.from(runtime) },
|
|
15
|
+
{ path: 'sound.mjs', mimeType: 'text/javascript', role: 'delivery', bytes: Buffer.from(audioLibraryModule(input.document, input.contentHash, engine.rendererBuildHash)) },
|
|
16
|
+
];
|
|
17
|
+
const files = members.map(({ bytes, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes) }));
|
|
18
|
+
const payload = { version: 1, sourcePath: input.sourcePath, sourceContentHash: input.contentHash, sourceRenderHash: input.sourceRenderHash,
|
|
19
|
+
engine: input.document.engine, rendererBuildHash: engine.rendererBuildHash, chromiumVersion: input.audition.chromiumVersion,
|
|
20
|
+
renderKey: input.audition.renderKey, pcmContentHash: input.audition.pcmContentHash, files };
|
|
21
|
+
const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash: audioRepresentationSetHash(payload) })}\n`);
|
|
22
|
+
const snapshot = { version: 1, target: input.target, revision: input.revision, sourcePath: input.sourcePath,
|
|
23
|
+
files: [...members.map(({ bytes, role: _role, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes), content: bytes.toString('base64') })),
|
|
24
|
+
{ path: 'manifest.json', mimeType: 'application/json', size: manifest.length, contentHash: digest(manifest), content: manifest.toString('base64') }] };
|
|
25
|
+
return inspectAudioLibrarySnapshot(snapshot).snapshot;
|
|
26
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { inflateRawSync } from 'node:zlib';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { audioError } from './storage.js';
|
|
4
|
+
const MAX_BYTES = 64 * 1024 * 1024, MAX_ENTRIES = 4096;
|
|
5
|
+
const MARKERS = { runtime: 'playcraft.audio-program.runtime.v1', prerender: 'playcraft.audio-program.prerender.v1' };
|
|
6
|
+
const invalid = () => audioError('audio_program_artifact_unverified', 422);
|
|
7
|
+
function crc32(bytes) {
|
|
8
|
+
let crc = 0xffffffff;
|
|
9
|
+
for (const byte of bytes) {
|
|
10
|
+
crc ^= byte;
|
|
11
|
+
for (let i = 0; i < 8; i++)
|
|
12
|
+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
13
|
+
}
|
|
14
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
/** Inspect archive members, never compressed binary substrings or evaluated project code. */
|
|
17
|
+
function zipTextEntries(bytes) {
|
|
18
|
+
let end = -1;
|
|
19
|
+
for (let i = bytes.length - 22, minimum = Math.max(0, bytes.length - 65557); i >= minimum; i--)
|
|
20
|
+
if (bytes.readUInt32LE(i) === 0x06054b50 && i + 22 + bytes.readUInt16LE(i + 20) === bytes.length) {
|
|
21
|
+
end = i;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if (end < 0 || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0)
|
|
25
|
+
throw invalid();
|
|
26
|
+
const count = bytes.readUInt16LE(end + 10), length = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
|
|
27
|
+
if (count > MAX_ENTRIES || count !== bytes.readUInt16LE(end + 8) || offset + length !== end)
|
|
28
|
+
throw invalid();
|
|
29
|
+
const entries = [];
|
|
30
|
+
let cursor = offset, inflated = 0;
|
|
31
|
+
for (let i = 0; i < count; i++) {
|
|
32
|
+
if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50)
|
|
33
|
+
throw invalid();
|
|
34
|
+
const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10), checksum = bytes.readUInt32LE(cursor + 16);
|
|
35
|
+
const packed = bytes.readUInt32LE(cursor + 20), size = bytes.readUInt32LE(cursor + 24), nameLength = bytes.readUInt16LE(cursor + 28);
|
|
36
|
+
const next = cursor + 46 + nameLength + bytes.readUInt16LE(cursor + 30) + bytes.readUInt16LE(cursor + 32), local = bytes.readUInt32LE(cursor + 42);
|
|
37
|
+
if (next > end || flags & 1 || ![0, 8].includes(method) || size > MAX_BYTES || local + 30 > offset || bytes.readUInt16LE(cursor + 34))
|
|
38
|
+
throw invalid();
|
|
39
|
+
const nameBytes = bytes.subarray(cursor + 46, cursor + 46 + nameLength), name = new TextDecoder('utf-8', { fatal: true }).decode(nameBytes);
|
|
40
|
+
if (!name || name.includes('\\') || name.startsWith('/') || name.split('/').includes('..') || name.includes('\0'))
|
|
41
|
+
throw invalid();
|
|
42
|
+
if (bytes.readUInt32LE(local) !== 0x04034b50 || bytes.readUInt16LE(local + 8) !== method || bytes.readUInt16LE(local + 6) !== flags)
|
|
43
|
+
throw invalid();
|
|
44
|
+
const localNameLength = bytes.readUInt16LE(local + 26), start = local + 30 + localNameLength + bytes.readUInt16LE(local + 28);
|
|
45
|
+
if (!bytes.subarray(local + 30, local + 30 + localNameLength).equals(nameBytes) || start + packed > offset)
|
|
46
|
+
throw invalid();
|
|
47
|
+
if (/\.(?:[cm]?js|html?)$/i.test(name)) {
|
|
48
|
+
if ((inflated += size) > MAX_BYTES)
|
|
49
|
+
throw invalid();
|
|
50
|
+
const data = bytes.subarray(start, start + packed), value = method === 0 ? data : inflateRawSync(data, { maxOutputLength: Math.max(1, size) });
|
|
51
|
+
if (value.length !== size || crc32(value) !== checksum)
|
|
52
|
+
throw invalid();
|
|
53
|
+
entries.push({ name, bytes: value });
|
|
54
|
+
}
|
|
55
|
+
cursor = next;
|
|
56
|
+
}
|
|
57
|
+
if (cursor !== end || !entries.length)
|
|
58
|
+
throw invalid();
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
61
|
+
export function auditAudioProgramArtifact(bytes, outputPath, variant) {
|
|
62
|
+
if (!bytes.length || bytes.length > MAX_BYTES || !['runtime', 'prerender'].includes(variant))
|
|
63
|
+
throw invalid();
|
|
64
|
+
const extension = extname(outputPath).toLowerCase(), format = extension === '.zip' ? 'zip' : extension === '.html' || extension === '.htm' ? 'html' : undefined;
|
|
65
|
+
if (!format)
|
|
66
|
+
throw invalid();
|
|
67
|
+
const entries = format === 'zip' ? zipTextEntries(bytes) : [{ name: outputPath, bytes }];
|
|
68
|
+
const result = { format, inspectedFiles: entries.length, inspectedBytes: 0, expectedMarkerCount: 0, forbiddenMarkerCount: 0, coverage: 'html-javascript-and-explicit-base64-data' };
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const inspect = (data, depth) => {
|
|
71
|
+
if ((result.inspectedBytes += data.length) > MAX_BYTES)
|
|
72
|
+
throw invalid();
|
|
73
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
74
|
+
result.expectedMarkerCount += text.split(MARKERS[variant]).length - 1;
|
|
75
|
+
result.forbiddenMarkerCount += text.split(MARKERS[variant === 'runtime' ? 'prerender' : 'runtime']).length - 1;
|
|
76
|
+
for (const match of text.matchAll(/data:(?:text\/(?:javascript|html)|application\/(?:javascript|json));base64,([A-Za-z0-9+/]+={0,2})/g)) {
|
|
77
|
+
if (depth >= 3 || match[1].length > MAX_BYTES * 4 / 3)
|
|
78
|
+
throw invalid();
|
|
79
|
+
if (seen.has(match[1]))
|
|
80
|
+
continue;
|
|
81
|
+
seen.add(match[1]);
|
|
82
|
+
inspect(Buffer.from(match[1], 'base64'), depth + 1);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
for (const entry of entries)
|
|
86
|
+
inspect(entry.bytes, 0);
|
|
87
|
+
if (result.forbiddenMarkerCount || !result.expectedMarkerCount)
|
|
88
|
+
throw Object.assign(invalid(), { details: { variant, ...result } });
|
|
89
|
+
return result;
|
|
90
|
+
}
|