diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 91e5c1a6..5c4d4d1c 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -37,6 +37,13 @@ import { * GET - 下载文件 */ export async function GET(request: NextRequest) { + // Token 绑定素材身份而非某次文件路径;再次访问必须解析当前版本。 + const response = await handleDownload(request); + response.headers.set('Cache-Control', 'private, no-store'); + return response; +} + +async function handleDownload(request: NextRequest) { const token = request.nextUrl.searchParams.get('token'); const rangeHeader = request.headers.get('range'); @@ -447,6 +454,7 @@ async function downloadSingleFile(asset: any, rangeHeader: string | null = null) if (isHttpUrl(normalizedFileUrl)) { try { const response = await fetch(normalizedFileUrl, { + cache: 'no-store', headers: rangeHeader ? { Range: rangeHeader } : undefined, }); if (!response.ok || !response.body) { diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index ac98906c..69c9a8bc 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -263,7 +263,10 @@ export async function GET(request: NextRequest) { userAgent: request.headers.get('user-agent') || undefined, }); - return successResponse(responseData); + // fetched 只是获取记录;同一素材后续仍会更新,不能缓存首次获取的清单。 + const response = successResponse(responseData); + response.headers.set('Cache-Control', 'private, no-store'); + return response; } else { // 其他素材类型:返回统一的 files 结构和单个下载链接 const previewUrl = material.previewUrl || ''; diff --git a/lib/__tests__/external-download-range.test.ts b/lib/__tests__/external-download-range.test.ts index 477a3709..c899be56 100644 --- a/lib/__tests__/external-download-range.test.ts +++ b/lib/__tests__/external-download-range.test.ts @@ -154,6 +154,28 @@ describe('external single-file S3 download range forwarding', () => { expect(new Headers(requestInit?.headers).get('range')).toBeNull() }) + test.each(['all', 'sku'])('相同 Token 重复下载 %s:重新解析最新文件和内容,不缓存旧版本', async (downloadType) => { + ;(verifyDownloadToken as jest.Mock).mockReturnValue({ materialId: 'material-1', downloadType }) + for (const revision of [1, 2, 3]) { + const latestUrl = `https://s3.example.test/materials/${downloadType}-v${revision}.zip` + const payload = Uint8Array.from([revision, revision + 1, revision + 2]) + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + ...makeMaterial(), taskStatus: 'fetched', + assets: [{ id: `asset-${downloadType}`, assetType: downloadType, fileUrl: latestUrl, fileName: `${downloadType}.zip` }], + }) + ;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(payload, { + headers: { 'Content-Type': 'application/zip', 'Content-Length': String(payload.length), 'Cache-Control': 'public, max-age=31536000' }, + })) + + const response = await GET(makeRequest()) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from(payload)) + expect(global.fetch).toHaveBeenLastCalledWith(latestUrl, expect.objectContaining({ cache: 'no-store' })) + } + expect(prisma.material.findUnique).toHaveBeenCalledTimes(3) + }) + describe('local file ranges', () => { beforeEach(() => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(makeMaterial(localFilePath)) diff --git a/lib/__tests__/product-assets-route.test.ts b/lib/__tests__/product-assets-route.test.ts index df7b4332..7c42cff5 100644 --- a/lib/__tests__/product-assets-route.test.ts +++ b/lib/__tests__/product-assets-route.test.ts @@ -225,6 +225,38 @@ describe('external product-assets route', () => { expect(logApiCall).toHaveBeenCalled() }) + test.each(['materialId', 'taskNo'])('%s:已获取素材连续更新后,每次查询返回最新正式图片包', async (queryBy) => { + const id = '35d8644b-1e09-4ce8-9bb0-c529b39ba52e' + const taskNo = 'TASK202601030184' + const lookup = (queryBy === 'materialId' ? getMaterialById : getMaterialByTaskNo) as jest.Mock + for (const revision of [1, 2, 3]) { + const updatedAt = new Date(`2026-10-29T0${revision}:00:00.000Z`) + const url = `uploads/materials/sku-v${revision}.zip` + lookup.mockResolvedValue({ + id, taskNo, materialType: '任务-详情', taskStatus: TASK_STATUS.FETCHED, + productModel: 'GBGJ-SZ0244', isFullSet: false, deletedAt: null, + createdAt: new Date('2026-09-15T00:00:00.000Z'), updatedAt, + assets: [{ id: 'asset-sku', assetType: 'sku', fileUrl: url, + fileName: `sku-v${revision}.zip`, fileSize: BigInt(revision * 100), sortOrder: 0 }], + }) + + const response = await GET(createRequest( + `https://example.com/api/external/product-assets?${queryBy}=${queryBy === 'materialId' ? id : taskNo}`, + )) + const body = await response.json() + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(body.data.updatedAt).toBe(updatedAt.toISOString()) + expect(body.data.assets.sku).toEqual([expect.objectContaining({ + id: 'asset-sku', url: `rewritten:${url}`, fileName: `sku-v${revision}.zip`, fileSize: revision * 100, + })]) + expect(body.data.downloadUrls.all).toBe(`https://download.example/${id}`) + } + + expect(lookup).toHaveBeenCalledTimes(3) + expect(updateTaskStatus).not.toHaveBeenCalled() + }) + test('未提供 taskNo 和 materialId 时应立即失败', async () => { const response = await GET(createRequest('https://example.com/api/external/product-assets')) const body = await response.json() diff --git a/lib/__tests__/task-detail-asset-edit-batch.test.ts b/lib/__tests__/task-detail-asset-edit-batch.test.ts index e65bbfbf..ac7a5581 100644 --- a/lib/__tests__/task-detail-asset-edit-batch.test.ts +++ b/lib/__tests__/task-detail-asset-edit-batch.test.ts @@ -115,6 +115,20 @@ type PreviewRow = { createdAt?: Date | null } +const material = { + id: 'material-1', + userId: 'operator-1', + materialType: '任务-详情', + taskStatus: 'completed', + isIncompleteShell: false, + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'https://preview/existing-cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:00:00.000Z'), +} + describe('task-detail asset edit batch data-plane', () => { let tempRoot: string @@ -128,18 +142,7 @@ describe('task-detail asset edit batch data-plane', () => { mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn(), })) - ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ - id: 'material-1', - userId: 'operator-1', - materialType: '任务-详情', - taskStatus: 'completed', - productModel: 'MODEL-1', - authorName: '作者A', - categoryName: '店铺A', - previewUrl: 'https://preview/existing-cover.webp', - isFullSet: false, - updatedAt: new Date('2026-08-28T08:00:00.000Z'), - }) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.update as jest.Mock).mockResolvedValue(undefined) @@ -261,7 +264,8 @@ describe('task-detail asset edit batch data-plane', () => { } } - test('sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async () => { + test.each(['completed', 'fetched'])('%s:sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async (taskStatus) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus }) const existingSource = await createSolidImage('sku-old.png', 80, 80) const existingPreview = await createSolidImage('sku-old-preview.webp', 80, 80, 'webp') const append1 = await createSolidImage('append-1.png', 90, 90) @@ -336,6 +340,108 @@ describe('task-detail asset edit batch data-plane', () => { }, data: expect.objectContaining({ status: 'applied', applyError: null }), }) + for (const [update] of (prisma.material.update as jest.Mock).mock.calls) { + expect(update.data).not.toHaveProperty('taskStatus') + expect(update.data).not.toHaveProperty('taskFetchedAt') + } + }) + + test.each([false, true])('已获取素材替换三张 SKU 图:总包重建失败=%s,正式版本整体切换', async (rebuildFails) => { + const fetchedAt = new Date('2026-09-22T09:54:50.875Z') + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus: 'fetched', taskFetchedAt: fetchedAt }) + const images = await Promise.all([1, 2, 3].map(async (index) => ({ + id: `sku-${index}`, name: `sku-${index}.png`, + source: await createSolidImage(`sku-${index}.png`, 80, 80), + preview: await createSolidImage(`sku-${index}-preview.webp`, 80, 80, 'webp'), + replacement: await createSolidImage(`replacement-${index}.png`, 90, 90), + }))) + configureUploadSources(Object.fromEntries(images.map((image, index) => [`upload-${index}`, { + path: image.replacement.filePath, size: image.replacement.size, fileName: `replacement-${index}.png`, + }]))) + configureAssetQueries({ + previewRows: images.map((image, index) => ({ + id: image.id, assetType: 'sku-preview', fileUrl: image.preview.filePath, + fileName: `sku-${index + 1}-preview.webp`, sortOrder: index, skuCode: `SKU-${index + 1}`, + })), + zipRows: ['main', 'sku', 'detail', 'all'].map((group) => ({ + id: `zip-${group}`, assetType: group, fileUrl: `uploads/materials/${group}-old.zip`, fileName: `${group}-old.zip`, + })), + }) + ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async ({ where }) => ( + where.fileUrl.in.map((fileUrl: string, index: number) => ({ id: `new-sku-${index + 1}`, fileUrl })) + )) + mockListZipEntries.mockResolvedValue([...images].reverse().map(({ name }) => ({ name }))) + mockExtractZipEntries.mockResolvedValue(images.map(({ name, source }) => ({ + entryName: name, filePath: source.filePath, size: source.size, + }))) + mockCreateZipFromFiles.mockResolvedValue({ filePath: 'uploads/materials/sku-new.zip', fileName: 'sku-new.zip', fileSize: 321 }) + if (rebuildFails) mockCreateZipFromZips.mockRejectedValueOnce(new Error('总包重建失败')) + else mockCreateZipFromZips.mockResolvedValue({ filePath: 'uploads/materials/all-new.zip', fileName: 'all-new.zip', fileSize: 654 }) + + const editing = batchEditTaskDetailImages({ + materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-atomic', + operations: images.map(({ id }, index) => ({ kind: 'replace', assetId: id, uploadId: `upload-${index}` })), + }) + if (rebuildFails) { + await expect(editing).rejects.toThrow('总包重建失败') + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.update).not.toHaveBeenCalled() + expect(prisma.materialAsset.update).not.toHaveBeenCalled() + expect(mockQueueSkuNotification).not.toHaveBeenCalled() + expect(mockCleanupConsumedUploadSession).not.toHaveBeenCalled() + return + } + await expect(editing).resolves.toMatchObject({ success: true, appliedCount: 3, entryCount: 3 }) + const rebuiltImages = mockCreateZipFromFiles.mock.calls[0][0] as Array<{ path: string; name: string }> + for (const image of images) { + expect(rebuiltImages).toContainEqual(expect.objectContaining({ name: image.name, path: image.replacement.filePath })) + } + expect(mockCreateZipFromZips.mock.calls[0][0]).toEqual([ + { path: 'uploads/materials/main-old.zip', name: 'main-old.zip' }, + { path: 'uploads/materials/sku-new.zip', name: 'sku-new.zip' }, + { path: 'uploads/materials/detail-old.zip', name: 'detail-old.zip' }, + ]) + expect(prisma.materialAsset.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'zip-sku' }, data: expect.objectContaining({ fileUrl: 'uploads/materials/sku-new.zip' }), + })) + expect(prisma.materialAsset.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'zip-all' }, data: expect.objectContaining({ fileUrl: 'uploads/materials/all-new.zip' }), + })) + expect(prisma.material.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ zipFilePath: 'uploads/materials/all-new.zip' }), + })) + const materialUpdate = (prisma.material.update as jest.Mock).mock.calls[0][0].data + expect(materialUpdate).not.toHaveProperty('taskStatus') + expect(materialUpdate).not.toHaveProperty('taskFetchedAt') + const insertedPreviews = (prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data + expect(insertedPreviews).toHaveLength(3) + expect(insertedPreviews.every((row: { assetType: string }) => row.assetType === 'sku-preview')).toBe(true) + expect(mockQueueSkuNotification).toHaveBeenCalledWith('material-1', ['new-sku-1', 'new-sku-2', 'new-sku-3']) + }) + + test.each([ + { taskStatus: 'pending', isIncompleteShell: false }, + { taskStatus: 'queued', isIncompleteShell: false }, + { taskStatus: 'uploading', isIncompleteShell: false }, + { taskStatus: 'processing', isIncompleteShell: false }, + { taskStatus: 'failed', isIncompleteShell: false }, + { taskStatus: null, isIncompleteShell: false }, + { taskStatus: 'unknown', isIncompleteShell: false }, + { taskStatus: 'completed', isIncompleteShell: true }, + { taskStatus: 'fetched', isIncompleteShell: true }, + ])('数据层在任何写入前拒绝未完成上传或空壳:%j', async (state) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, ...state }) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [{ kind: 'replace', assetId: 'sku-1', uploadId: 'upload-1' }], + })).rejects.toThrow('素材尚未完成上传') + + expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + expect(mockCleanupConsumedUploadSession).not.toHaveBeenCalled() }) test('详情插图要求 seam 两侧 preview 必须相邻', async () => { @@ -967,14 +1073,14 @@ describe('task-detail asset edit batch data-plane', () => { expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) - test('私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async () => { + test.each(['completed', 'fetched'])('%s:私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async (taskStatus) => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'owner-2', materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'owner-2', - taskStatus: 'completed', + taskStatus, productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', @@ -997,12 +1103,12 @@ describe('task-detail asset edit batch data-plane', () => { expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() }) - test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => { + test.each(['completed', 'fetched'])('%s:审批期间正式素材已更新时在持锁上下文中停止应用', async (taskStatus) => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'operator-1', materialType: '任务-详情', - taskStatus: 'completed', + taskStatus, productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', diff --git a/lib/__tests__/task-detail-change-approval.test.ts b/lib/__tests__/task-detail-change-approval.test.ts index 76983494..f791546e 100644 --- a/lib/__tests__/task-detail-change-approval.test.ts +++ b/lib/__tests__/task-detail-change-approval.test.ts @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma' import { getEmployeeByUserId } from '@/lib/employee-directory' import { canManageMaterial } from '@/lib/material-authorization' +import { canUserAccessPrivateTaskDetail, canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' import { assertTaskDetailImageEditReady, batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit' import { cleanupTaskDetailChangeStaging, @@ -50,7 +51,10 @@ jest.mock('@/lib/performance-survey-report-sync', () => ({ })) jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) -jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() })) +jest.mock('@/lib/user-permission-inherit', () => ({ + canUserEditTaskDetailAssets: jest.fn(), + canUserAccessPrivateTaskDetail: jest.fn(), +})) jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'), })) @@ -78,6 +82,7 @@ const material = { userId: 'owner-1', materialType: '任务-详情', taskStatus: 'completed', + isIncompleteShell: false, taskNo: 'TASK-001', productModel: 'SPU-001', updatedAt: new Date('2026-08-28T00:00:00.000Z'), @@ -129,6 +134,8 @@ describe('任务详情图片变更主管审批', () => { ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null) ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) + ;(canUserEditTaskDetailAssets as jest.Mock).mockResolvedValue(false) + ;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(false) ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) ;(assertTaskDetailImageEditReady as jest.Mock).mockResolvedValue(undefined) ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') @@ -141,7 +148,8 @@ describe('任务详情图片变更主管审批', () => { }) }) - test('M 级人员有权限时直接应用,不创建审批单', async () => { + test.each(['completed', 'fetched'])('%s:M 级人员有权限时直接应用,不创建审批单', async (taskStatus) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus }) ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M')) const result = await submitTaskDetailChange('material-1', { @@ -157,7 +165,15 @@ describe('任务详情图片变更主管审批', () => { expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() }) - test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => { + test.each(['completed', 'fetched'])('%s:P 级人员替换三张 SKU 图仍须持久暂存并创建 24 小时审批单', async (taskStatus) => { + const operations = [1, 2, 3].map((index) => ({ + kind: 'replace', assetId: `sku-${index}`, uploadId: `upload-${index}`, + })) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...material, + taskStatus, + assets: operations.map(({ assetId }) => ({ id: assetId, assetType: 'sku-preview' })), + }) ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'manager-employee', @@ -170,9 +186,13 @@ describe('任务详情图片变更主管审批', () => { positionLevel: 'M1', userBindings: [{ userId: 'manager-user' }], }) - ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([ - ['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }], - ]))) + ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map( + operations.map(({ uploadId }, index) => [uploadId, { + relativePath: `task-detail-change-requests/request/new-${index}.png`, + fileName: `new-${index}.png`, + fileSize: 10, + }]), + ))) ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, @@ -182,25 +202,74 @@ describe('任务详情图片变更主管审批', () => { const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', - }, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }]) + }, operations) expect(result.mode).toBe('pending_approval') expect(batchEditTaskDetailImages).not.toHaveBeenCalled() expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({ ownerUserId: 'user-1', - uploadIds: ['upload-1'], + uploadIds: ['upload-1', 'upload-2', 'upload-3'], })) expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'pending_manager_approval', managerDingtalkUserId: 'manager-ding', - operations: [expect.objectContaining({ + operations: operations.map(({ assetId }, index) => expect.objectContaining({ kind: 'replace', - assetId: 'asset-1', - stagedFile: expect.objectContaining({ fileName: 'new.png' }), - })], + assetId, + stagedFile: expect.objectContaining({ fileName: `new-${index}.png` }), + })), }), })) + expect(assertTaskDetailImageEditReady).toHaveBeenCalledWith(expect.objectContaining({ group: 'sku' })) + expect(queueTaskDetailChangeNotification).toHaveBeenCalledTimes(1) + }) + + test.each([ + { taskStatus: 'pending', isIncompleteShell: false }, + { taskStatus: 'queued', isIncompleteShell: false }, + { taskStatus: 'uploading', isIncompleteShell: false }, + { taskStatus: 'processing', isIncompleteShell: false }, + { taskStatus: 'failed', isIncompleteShell: false }, + { taskStatus: null, isIncompleteShell: false }, + { taskStatus: 'unknown', isIncompleteShell: false }, + { taskStatus: 'completed', isIncompleteShell: true }, + { taskStatus: 'fetched', isIncompleteShell: true }, + ])('未完成上传或空壳仍不能提交变更:%j', async (state) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, ...state }) + + await expect(submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [ + { kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }, + ])).rejects.toThrow('素材尚未完成上传') + + expect(assertTaskDetailImageEditReady).not.toHaveBeenCalled() + expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() + expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + expect(queueTaskDetailChangeNotification).not.toHaveBeenCalled() + }) + + test('已被获取不授予编辑权限,也不绕过私密素材准入', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus: 'fetched' }) + ;(canManageMaterial as jest.Mock).mockResolvedValue(false) + await expect(submitTaskDetailChange('material-1', { id: 'user-1' }, [ + { kind: 'delete', assetId: 'asset-1' }, + ])).rejects.toMatchObject({ statusCode: 403 }) + + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...material, + taskStatus: 'fetched', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'another-user', + }) + ;(canUserEditTaskDetailAssets as jest.Mock).mockResolvedValue(true) + await expect(submitTaskDetailChange('material-1', { id: 'user-1' }, [ + { kind: 'delete', assetId: 'asset-1' }, + ])).rejects.toMatchObject({ statusCode: 403 }) + + expect(assertTaskDetailImageEditReady).not.toHaveBeenCalled() + expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() }) test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => { diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index de5ed409..48bec58c 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -568,6 +568,7 @@ async function loadEditContext(options: { ...TASK_DETAIL_PRIVACY_SELECT, materialType: true, taskStatus: true, + isIncompleteShell: true, productModel: true, authorName: true, categoryName: true, @@ -586,7 +587,11 @@ async function loadEditContext(options: { if (!isProductPackageType(material.materialType)) { throw new TaskDetailAssetEditError('只有任务-详情类型支持单图编辑') } - if (material.taskStatus !== 'completed') { + // 与审批入口一致:外部获取不撤销上传完成状态,空壳仍禁止编辑。 + if ( + material.isIncompleteShell + || (material.taskStatus !== 'completed' && material.taskStatus !== 'fetched') + ) { throw new TaskDetailAssetEditError('素材尚未完成上传,暂不能编辑图片') } diff --git a/lib/task-detail-change-approval.ts b/lib/task-detail-change-approval.ts index 045e4de6..0b86ac79 100644 --- a/lib/task-detail-change-approval.ts +++ b/lib/task-detail-change-approval.ts @@ -180,6 +180,7 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { materialType: true, ...TASK_DETAIL_PRIVACY_SELECT, taskStatus: true, + isIncompleteShell: true, taskNo: true, productModel: true, updatedAt: true, @@ -198,7 +199,11 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { if (!isProductPackageType(material.materialType)) { throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更') } - if (material.taskStatus !== 'completed') { + // fetched 是上传完成后的外部获取状态,不能当作仍未完成上传。 + if ( + material.isIncompleteShell + || (material.taskStatus !== 'completed' && material.taskStatus !== 'fetched') + ) { throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片') } diff --git a/scripts/check-material-preview-health.mjs b/scripts/check-material-preview-health.mjs new file mode 100644 index 00000000..9c9496d7 --- /dev/null +++ b/scripts/check-material-preview-health.mjs @@ -0,0 +1,291 @@ +// Standalone, opt-in read-only acceptance for reviewed, registered material URLs. +// LEGACY_READ_ONLY: reuse the project's historical host registry without rewriting URLs. +// node scripts/check-material-preview-health.mjs --manifest +// {"version":1,"reviewed":true,"materials":[{"materialId":"","registeredSource":""}]} +import { createHash } from 'node:crypto' +import { lookup } from 'node:dns/promises' +import { open } from 'node:fs/promises' +import http from 'node:http' +import https from 'node:https' +import { isIP } from 'node:net' +import { createRequire } from 'node:module' +import path from 'node:path' +import { Readable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +export const LIMITS = Object.freeze({ timeoutMs: 10_000, maxBytes: 8 * 1024 * 1024, maxPixels: 25_000_000, materials: 50 }) +const CURRENT_HOST = 's3.jiansun.vip' +const HOSTS = new Set([CURRENT_HOST, ...LEGACY_DOMAINS]) +const MIME_FORMATS = new Map([['image/png', 'png'], ['image/jpeg', 'jpeg'], ['image/jpg', 'jpeg'], ['image/webp', 'webp']]) +const sha256 = value => createHash('sha256').update(value).digest('hex') + +class CheckError extends Error { + constructor(classification, code, materialId) { + super(code) + this.classification = classification + this.code = code + this.materialId = materialId + } +} + +export function registeredSource(value) { + if (typeof value !== 'string' || value.length > 4096 || value.trim() !== value || /[\r\n\0]/.test(value)) { + throw new CheckError('source_rejected', 'invalid_url') + } + let url + try { url = new URL(value) } catch { throw new CheckError('source_rejected', 'invalid_url') } + if (!HOSTS.has(url.hostname) || url.username || url.password || url.port || url.hash) { + throw new CheckError('source_rejected', 'unregistered_source') + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LEGACY_DOMAINS.includes(url.hostname))) { + throw new CheckError('source_rejected', 'unregistered_protocol') + } + if (!url.pathname || url.pathname === '/') throw new CheckError('source_rejected', 'object_path_required') + return url +} + +export function validateManifest(manifest) { + if (manifest?.version !== 1 || manifest.reviewed !== true || !Array.isArray(manifest.materials) + || manifest.materials.length < 1 || manifest.materials.length > LIMITS.materials) { + throw new CheckError('invalid_manifest', 'reviewed_manifest_required') + } + const identities = new Set() + return manifest.materials.map(item => { + if (typeof item?.materialId !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(item.materialId) || identities.has(item.materialId)) { + throw new CheckError('invalid_manifest', 'unique_material_id_required') + } + identities.add(item.materialId) + try { + const url = registeredSource(item.registeredSource) + return { materialId: item.materialId, url } + } catch (error) { + error.materialId = item.materialId + throw error + } + }) +} + +// A reviewed hostname must not be rebound to local/private/metadata networks. +// The direct Node transport resolves once per connection and uses only these +// checked addresses; environment proxy variables and redirects are not followed. +export function isPublicAddress(address) { + if (isIP(address) === 4) { + const [a, b, c] = address.split('.').map(Number) + return !(a === 0 || a === 10 || a === 127 || a >= 224 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) + || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) + || (a === 203 && b === 0 && c === 113)) + } + if (isIP(address) === 6) { + // Only global unicast; this also rejects mapped IPv4, loopback and link-local. + const first = Number.parseInt(address.split(':')[0], 16) + return first >= 0x2000 && first <= 0x3fff && first !== 0x2002 && !/^2001:(?:0*:|0*db8:)/i.test(address) + } + return false +} + +export function directFetch(input, { signal, headers, method, cache, redirect } = {}) { + const url = registeredSource(String(input)) + if (method !== 'GET' || cache !== 'no-store' || redirect !== 'manual') { + throw new CheckError('source_rejected', 'readonly_transport_required') + } + return new Promise((resolve, reject) => { + const transport = url.protocol === 'https:' ? https : http + const request = transport.request(url, { + method: 'GET', signal, headers, + lookup(hostname, options, callback) { + lookup(hostname, { all: true, verbatim: true }).then(addresses => { + if (hostname !== url.hostname || !addresses.length || addresses.some(row => !isPublicAddress(row.address))) { + callback(new CheckError('source_rejected', 'non_public_dns_address')) + } else if (options?.all) { + callback(null, addresses) + } else { + callback(null, addresses[0].address, addresses[0].family) + } + }, () => callback(new CheckError('network_failure', 'dns_lookup_failed'))) + }, + }, response => { + try { + const noBody = [204, 205, 304].includes(response.statusCode) + if (noBody) response.destroy() + resolve(new Response(noBody ? null : Readable.toWeb(response), { + status: response.statusCode, + headers: response.headers, + })) + } catch { + response.destroy() + reject(new CheckError('network_failure', 'invalid_http_response')) + } + }) + request.on('error', reject) + request.end() + }) +} + +function formatOf(bytes) { + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'png' + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg' + if (bytes.length >= 16 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' + && ['VP8 ', 'VP8L', 'VP8X'].includes(bytes.toString('ascii', 12, 16))) return 'webp' + return null +} + +/** Lazy CJS loading also works when this module's source is run through Node stdin. */ +export function createSharpPreviewDecoder({ requireImpl = createRequire(import.meta.url) } = {}) { + let sharp + return async bytes => { + if (!sharp) { + try { sharp = requireImpl('sharp') } catch { throw new CheckError('decoder_unavailable', 'image_decoder_unavailable') } + } + try { + const image = sharp(bytes, { failOn: 'error', limitInputPixels: LIMITS.maxPixels, animated: true }) + const metadata = await image.metadata() + // metadata() alone only parses the header. Materialise pixels so a + // truncated/corrupt PNG, JPEG or WebP cannot pass from its magic bytes. + const { info } = await image.raw().toBuffer({ resolveWithObject: true }) + return { format: metadata.format, width: info.width, height: info.height } + } catch (error) { + if (error instanceof CheckError) throw error + throw new CheckError('unexpected_content', 'image_decode_failed') + } + } +} +const defaultImageDecoder = createSharpPreviewDecoder() + +function sourceReference(url) { + return { origin: url.origin, pathname: url.pathname, queryRedacted: Boolean(url.search), sha256: sha256(url.href) } +} + +function withAbort(promise, signal) { + if (signal.aborted) return Promise.reject(signal.reason) + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)) + }) +} + +async function checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes }) { + const base = { materialId: entry.materialId, source: sourceReference(entry.url), attempts: 1 } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new CheckError('timeout', 'request_timeout')), timeoutMs) + let reader + let body + let status = null + let contentType = '' + let bytesRead = 0 + let bodySha256 + const failure = (classification, code) => ({ ...base, ok: false, classification, code, status, contentType, bytes: bytesRead, + ...(bodySha256 ? { bodySha256 } : {}) }) + try { + const response = await withAbort(fetchImpl(entry.url.href, { + method: 'GET', cache: 'no-store', redirect: 'manual', signal: controller.signal, + headers: { 'Cache-Control': 'no-store, no-cache, max-age=0', Pragma: 'no-cache', Accept: 'image/png,image/jpeg,image/webp' }, + }), controller.signal) + status = response.status + body = response.body + contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + if (response.redirected || (status >= 300 && status < 400)) return failure('redirect_blocked', 'redirect_not_followed') + if (response.url && response.url !== entry.url.href) return failure('redirect_blocked', 'response_url_changed') + const length = response.headers.get('content-length') + if (length && /^\d+$/.test(length) && Number(length) > maxBytes) return failure('too_large', 'declared_body_limit') + const chunks = [] + reader = response.body?.getReader() + while (reader) { + const { done, value } = await withAbort(reader.read(), controller.signal) + if (done) break + bytesRead += value.byteLength + if (bytesRead > maxBytes) return failure('too_large', 'stream_body_limit') + chunks.push(Buffer.from(value)) + } + const bytes = Buffer.concat(chunks) + // A fully read failure body can identify the known incident without storing + // its contents. Partial, oversized and unavailable bodies have no digest. + bodySha256 = sha256(bytes) + const prefix = bytes.subarray(0, 4096).toString('utf8').replace(/^\uFEFF/, '').trimStart() + const html = contentType === 'text/html' || /^(?:\s*frp\s*<\/center>)/i.test(prefix) + if (html) return failure('routing_failure', status === 404 && frpMarker ? 'frp_default_404' : 'html_instead_of_image') + if (status === 404 && /\s*NoSuchKey\s*<\/Code>/i.test(prefix)) return failure('missing_object', 's3_no_such_key') + if (status === 404 && /\s*NoSuchBucket\s*<\/Code>/i.test(prefix)) return failure('missing_bucket', 's3_no_such_bucket') + if (!response.ok) return failure('http_failure', 'http_status') + if (!MIME_FORMATS.has(contentType)) return failure('unexpected_content', 'unsupported_image_content_type') + const format = formatOf(bytes) + if (!format || MIME_FORMATS.get(contentType) !== format) return failure('unexpected_content', 'image_signature_mismatch') + const decoded = await withAbort(decodeImage(bytes), controller.signal) + if (decoded?.format !== format || !Number.isSafeInteger(decoded.width) || !Number.isSafeInteger(decoded.height) + || decoded.width < 1 || decoded.height < 1 || decoded.width * decoded.height > LIMITS.maxPixels) { + return failure('unexpected_content', 'image_decode_result_invalid') + } + return { ...base, ok: true, classification: 'healthy', status, contentType, bytes: bytesRead, format, + decoded: { width: decoded.width, height: decoded.height }, bodySha256: sha256(bytes) } + } catch (error) { + if (controller.signal.aborted) return failure('timeout', 'request_timeout') + if (error instanceof CheckError) return failure(error.classification, error.code) + // Transport messages can contain signed query strings. Keep raw exceptions out of reports. + return failure('network_failure', 'request_failed') + } finally { + clearTimeout(timer) + if (reader) reader.cancel().catch(() => {}) + else if (body) body.cancel().catch(() => {}) + controller.abort() + } +} + +export async function checkMaterialPreviewHealth(manifest, { fetchImpl = directFetch, decodeImage = defaultImageDecoder, + timeoutMs = LIMITS.timeoutMs, maxBytes = LIMITS.maxBytes } = {}) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > LIMITS.timeoutMs + || !Number.isSafeInteger(maxBytes) || maxBytes < 16 || maxBytes > LIMITS.maxBytes || typeof decodeImage !== 'function') { + throw new CheckError('invalid_manifest', 'limits_must_not_exceed_fixed_budget') + } + // Validate the entire manifest before any network request, including mixed lists. + const entries = validateManifest(manifest) + const results = [] + for (const entry of entries) results.push(await checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes })) + return { version: 1, readOnly: true, checkedAt: new Date().toISOString(), manifestSha256: sha256(JSON.stringify(manifest)), + passed: results.every(result => result.ok), limits: { timeoutMs, maxBytes, maxPixels: LIMITS.maxPixels }, retries: 0, results } +} + +export async function main(args = process.argv.slice(2)) { + const options = {} + for (let i = 0; i < args.length; i += 2) { + if (!['--manifest', '--timeout-ms', '--max-bytes'].includes(args[i]) || args[i + 1] === undefined || options[args[i]] !== undefined) { + throw new CheckError('invalid_manifest', 'usage_manifest_path_required') + } + options[args[i]] = args[i + 1] + } + if (!options['--manifest']) throw new CheckError('invalid_manifest', 'usage_manifest_path_required') + const file = await open(options['--manifest'], 'r') + const buffer = Buffer.alloc(256 * 1024 + 1) + let raw + try { + const stat = await file.stat() + if (!stat.isFile() || stat.size > buffer.byteLength - 1) throw new CheckError('invalid_manifest', 'manifest_too_large_or_not_regular') + const { bytesRead } = await file.read(buffer, 0, buffer.byteLength, 0) + if (bytesRead >= buffer.byteLength) throw new CheckError('invalid_manifest', 'manifest_too_large') + raw = buffer.subarray(0, bytesRead) + } finally { + await file.close() + } + const manifest = JSON.parse(raw.toString('utf8')) + return checkMaterialPreviewHealth(manifest, { + ...(options['--timeout-ms'] ? { timeoutMs: Number(options['--timeout-ms']) } : {}), + ...(options['--max-bytes'] ? { maxBytes: Number(options['--max-bytes']) } : {}), + }) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(report => { + console.log(JSON.stringify(report, null, 2)) + process.exitCode = report.passed ? 0 : 1 + }, error => { + console.log(JSON.stringify({ version: 1, readOnly: true, passed: false, + classification: error instanceof CheckError ? error.classification : 'invalid_manifest', + code: error instanceof CheckError ? error.code : 'manifest_unreadable_or_invalid', + ...(error.materialId ? { materialId: error.materialId } : {}), results: [] }, null, 2)) + process.exitCode = 1 + }) +} diff --git a/scripts/check-material-preview-health.test.mjs b/scripts/check-material-preview-health.test.mjs new file mode 100644 index 00000000..74962b64 --- /dev/null +++ b/scripts/check-material-preview-health.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import test from 'node:test' +import { createRequire } from 'node:module' +import { checkMaterialPreviewHealth, createSharpPreviewDecoder, directFetch, isPublicAddress, LIMITS, registeredSource, validateManifest } from './check-material-preview-health.mjs' + +const sharp = createRequire(import.meta.url)('sharp') +const fixture = () => sharp({ create: { width: 3, height: 2, channels: 3, background: '#345678' } }) +const PNG = await fixture().png().toBuffer() +const JPEG = await fixture().jpeg().toBuffer() +const WEBP = await fixture().webp().toBuffer() +const source = 'https://s3.jiansun.vip/gongxiangpan/public-display/example.png' +const manifest = (url = source) => ({ version: 1, reviewed: true, materials: [{ materialId: 'material-a', registeredSource: url }] }) +const response = (body, type, status = 200, extra = {}) => new Response(body, { status, headers: { 'content-type': type, ...extra } }) + +test('complete failure bodies retain a digest for exact incident baselines without retaining raw contents', async () => { + const body = 'Faithfully yours, frp.' + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, 'text/html', 404) }) + assert.equal(report.results[0].ok, false) + assert.equal(report.results[0].bodySha256, createHash('sha256').update(body).digest('hex')) + assert.equal(JSON.stringify(report).includes(body), false) +}) + +test('normal PNG/JPEG/WebP use one actual uncached GET with no redirects or credentials', async () => { + for (const [body, mime, format] of [[PNG, 'image/png', 'png'], [JPEG, 'image/jpeg', 'jpeg'], [WEBP, 'image/webp', 'webp']]) { + const calls = [] + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async (url, options) => { + calls.push({ url, options }) + return response(body, `${mime}; charset=binary`) + } }) + assert.equal(report.passed, true) + assert.equal(report.readOnly, true) + assert.equal(report.retries, 0) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, source) + assert.equal(calls[0].options.method, 'GET') + assert.equal(calls[0].options.cache, 'no-store') + assert.equal(calls[0].options.redirect, 'manual') + assert.match(calls[0].options.headers['Cache-Control'], /no-store/) + assert.equal(calls[0].options.headers.Authorization, undefined) + assert.equal(report.results[0].classification, 'healthy') + assert.equal(report.results[0].format, format) + assert.deepEqual(report.results[0].decoded, { width: 3, height: 2 }) + assert.equal(report.results[0].bytes, body.length) + assert.match(report.results[0].bodySha256, /^[a-f0-9]{64}$/) + } +}) + +test('correct MIME and magic bytes cannot make truncated image headers healthy', async () => { + for (const [body, mime] of [ + [PNG.subarray(0, 16), 'image/png'], + [JPEG.subarray(0, 16), 'image/jpeg'], + [WEBP.subarray(0, 20), 'image/webp'], + ]) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, mime) }) + assert.equal(report.passed, false) + assert.equal(report.results[0].code, 'image_decode_failed') + } +}) + +test('decoder is lazy, injectable, and a missing runtime decoder fails closed', async () => { + let requires = 0 + const unavailable = createSharpPreviewDecoder({ requireImpl() { requires++; throw new Error('sensitive runtime path') } }) + assert.equal(requires, 0) + const report = await checkMaterialPreviewHealth(manifest(), { decodeImage: unavailable, fetchImpl: async () => response(PNG, 'image/png') }) + assert.equal(requires, 1) + assert.equal(report.results[0].classification, 'decoder_unavailable') + assert.equal(report.results[0].code, 'image_decoder_unavailable') + assert.ok(!JSON.stringify(report).includes('sensitive runtime path')) + const invalid = await checkMaterialPreviewHealth(manifest(), { decodeImage: async () => ({ format: 'png', width: 0, height: 2 }), fetchImpl: async () => response(PNG, 'image/png') }) + assert.equal(invalid.results[0].code, 'image_decode_result_invalid') +}) + +test('pixel bombs cannot pass the native decoder budget', async () => { + const large = await sharp({ create: { width: 5001, height: 5000, channels: 3, background: '#ffffff' } }).png().toBuffer() + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(large, 'image/png') }) + assert.equal(report.passed, false) + assert.equal(report.results[0].code, 'image_decode_failed') +}) + +test('FRP default 404 is distinguished from a missing S3 object and never retried', async () => { + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest('https://s3.1080p.hk/jsxiangche/a.png'), { fetchImpl: async () => { + calls++ + return response('

404 Not Found


frp
', 'text/html', 404) + } }) + assert.equal(calls, 1) + assert.equal(report.passed, false) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'frp_default_404') + const missing = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response('NoSuchKey', 'application/xml', 404) }) + assert.equal(missing.results[0].classification, 'missing_object') +}) + +test('real FRP Not Found page is identified by HTTP status and explicit marker without digits in its body', async () => { + const html = 'Not Found

Not Found

powered by frp

Faithfully yours, frp

' + assert.equal(html.includes('404'), false) + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => { calls++; return response(html, 'text/html', 404) } }) + assert.equal(calls, 1) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'frp_default_404') + const fakeSuccess = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(html, 'text/html', 200) }) + assert.equal(fakeSuccess.results[0].code, 'html_instead_of_image') +}) + +test('HTML disguised as successful image content is an entrance routing failure', async () => { + for (const type of ['text/html', 'image/png']) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response('wrong gateway', type) }) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'html_instead_of_image') + } +}) + +test('declared and streaming byte limits are enforced without consuming an unbounded body', async () => { + let read = 0 + let cancelled = false + const declared = await checkMaterialPreviewHealth(manifest(), { maxBytes: 16, fetchImpl: async () => response(new ReadableStream({ + pull(controller) { read++; controller.enqueue(new Uint8Array(32)) }, + cancel() { cancelled = true }, + }), 'image/png', 200, { 'content-length': '999999999' }) }) + assert.equal(declared.results[0].code, 'declared_body_limit') + assert.equal(declared.results[0].bytes, 0) + assert.ok(read <= 1, 'the declared limit is checked before reading') + assert.equal(cancelled, true, 'rejected response bodies are cancelled even before acquiring a reader') + const streamed = await checkMaterialPreviewHealth(manifest(), { maxBytes: 16, fetchImpl: async () => response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(12)); controller.enqueue(new Uint8Array(12)); controller.close() }, + }), 'image/png') }) + assert.equal(streamed.results[0].classification, 'too_large') + assert.equal(streamed.results[0].code, 'stream_body_limit') +}) + +test('redirects never follow a second host, including a permitted destination', async () => { + for (const target of ['http://127.0.0.1/private', 'https://s3.jiansun.vip/gongxiangpan/b.png']) { + const calls = [] + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async (url, options) => { + calls.push({ url, options }) + return response('', 'text/html', 302, { location: target }) + } }) + assert.equal(calls.length, 1) + assert.equal(calls[0].options.redirect, 'manual') + assert.equal(report.results[0].classification, 'redirect_blocked') + } + const changed = response(PNG, 'image/png') + Object.defineProperty(changed, 'url', { value: 'https://unregistered.example/p.png' }) + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => changed }) + assert.equal(report.results[0].code, 'response_url_changed') +}) + +test('unreviewed, duplicate and mixed invalid manifests fail before any fetch', async () => { + let calls = 0 + const fetchImpl = async () => { calls++; return response(PNG, 'image/png') } + for (const value of [ + { ...manifest(), reviewed: false }, + { ...manifest(), materials: [...manifest().materials, ...manifest().materials] }, + { ...manifest(), materials: [...manifest().materials, { materialId: 'b', registeredSource: 'http://169.254.169.254/latest/meta-data' }] }, + ]) await assert.rejects(checkMaterialPreviewHealth(value, { fetchImpl })) + assert.equal(calls, 0) +}) + +test('only exact registered hosts and approved schemes/ports are accepted', () => { + for (const url of [ + 'http://127.0.0.1/private', 'http://192.168.58.20:9000/object.png', 'https://s3.jiansun.vip.evil.example/a.png', + 'https://sub.s3.jiansun.vip/a.png', 'https://s3.jiansun.vip:444/a.png', 'https://user:pass@s3.jiansun.vip/a.png', + 'http://s3.jiansun.vip/a.png', 'file:///C:/Windows/a.png', 'https://s3.jiansun.vip/', 'https://s3.jiansun.vip/a.png#secret', + ]) assert.throws(() => registeredSource(url), { classification: 'source_rejected' }) + assert.equal(registeredSource('http://s3.1080p.hk/jsxiangche/a.png').protocol, 'http:') + assert.equal(registeredSource('https://s3.yydd.ai/gongxiangpan/a.png').hostname, 's3.yydd.ai') + assert.equal(validateManifest(manifest()).length, 1) + assert.throws(() => directFetch('https://evil.example/a.png', { method: 'GET', cache: 'no-store', redirect: 'manual' }), { classification: 'source_rejected' }) +}) + +test('DNS guard rejects local, private, mapped and special-purpose addresses', () => { + for (const ip of ['0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.169.254', '172.16.0.1', '192.168.1.1', '198.18.0.1', '224.0.0.1', '::1', '::ffff:127.0.0.1', 'fe80::1', 'fc00::1', '2001:db8::1', '2002:7f00:1::1']) { + assert.equal(isPublicAddress(ip), false, ip) + } + for (const ip of ['218.93.9.38', '156.233.232.106', '2606:4700::1111']) assert.equal(isPublicAddress(ip), true, ip) +}) + +test('timeouts and network errors remain single-attempt failures and hide signed URL secrets', async () => { + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest(), { timeoutMs: 5, fetchImpl: () => { calls++; return new Promise(() => {}) } }) + assert.equal(report.results[0].classification, 'timeout') + assert.equal(calls, 1) + const secret = 'never-print-this-signature' + const failure = await checkMaterialPreviewHealth(manifest(`${source}?X-Amz-Signature=${secret}`), { fetchImpl: async () => { throw new Error(secret) } }) + assert.equal(failure.results[0].classification, 'network_failure') + assert.equal(failure.results[0].source.queryRedacted, true) + assert.ok(!JSON.stringify(failure).includes(secret)) + await assert.rejects(checkMaterialPreviewHealth(manifest(), { timeoutMs: LIMITS.timeoutMs + 1 })) +}) + +test('image content type and magic must agree; JSON and empty images cannot pass', async () => { + for (const [body, type] of [['{}', 'application/json'], ['', 'image/png'], [JPEG, 'image/png']]) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, type) }) + assert.equal(report.passed, false) + assert.equal(report.results[0].classification, 'unexpected_content') + } +}) diff --git a/scripts/desktop-release-admission.py b/scripts/desktop-release-admission.py new file mode 100644 index 00000000..e0544a44 --- /dev/null +++ b/scripts/desktop-release-admission.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Project-scoped, read-only traffic admission; attestation never calls a model.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time +import urllib.request + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +RECEIPTS = POLICY / 'receipts' +ENV_FILE = BASE / '.env' +PRIVATE = BASE / 'detail-generator-private' +PROXY = BASE / 'local-proxy/youyidian-3d-material/nginx.conf' +NETWORK = 'youyidian-3d_app-network' +NAMES = {'youyidian-3d-material-blue', 'youyidian-3d-material-green'} +VERSION = 1 + + +class Rejected(Exception): + """Only fixed, non-secret reason codes may leave this process.""" + + +def require(condition, code): + if not condition: + raise Rejected(code) + + +def command(args): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=12) + require(result.returncode == 0, 'runtime_inspection_failed') + return result.stdout + except (OSError, subprocess.TimeoutExpired): + raise Rejected('runtime_inspection_failed') from None + + +def physical(path, directory=False, owner=None, mode=None): + """Never follow a substituted private store, policy file or ancestor.""" + path = Path(path) + for part in (path, *path.parents): + require(not part.is_symlink(), 'symlink_rejected') + try: + value = path.stat() + except OSError: + raise Rejected('required_path_missing') from None + require(stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode), + 'path_type_mismatch') + if owner is not None: + require((value.st_uid, value.st_gid) == owner, 'path_owner_mismatch') + if mode is not None: + require(stat.S_IMODE(value.st_mode) == mode, 'path_mode_mismatch') + return value + + +def enabled_from_env(text): + # Accept the unambiguous subset of Docker --env-file syntax. In particular, + # quotes/comments in a value are literal, not dotenv syntax. Ambiguity stops + # the release instead of silently turning authorization off. + values = [] + for line in text.removeprefix('\ufeff').splitlines(): + line = line.lstrip() + if not line or line.startswith('#'): + continue + key, sep, value = line.partition('=') + if key == 'DETAIL_GENERATOR_ENABLED': + require(sep and value in ('true', 'false'), 'approved_flag_invalid') + values.append(value) + elif key.strip() == 'DETAIL_GENERATOR_ENABLED': + raise Rejected('approved_flag_invalid') + require(len(values) == 1, 'approved_flag_ambiguous') + return values[0] == 'true' + + +def environment_values(lines, key): + return [line.partition('=')[2] for line in lines if line.partition('=')[0] == key] + + +def config_target(text): + names = set(re.findall(r'^\s*add_header X-Material-Proxy-Target ([^;\s]+) always;\s*$', text, re.M)) + ips = set(re.findall(r'^\s*server (\d+\.\d+\.\d+\.\d+):3000\s+[^;]*;\s*$', text, re.M)) + require(len(names) == 1 and names <= NAMES and len(ips) == 1, 'proxy_target_ambiguous') + return next(iter(names)), next(iter(ips)) + + +def inspect_target(name, expected_enabled, private_identity): + require(name in NAMES, 'container_scope_rejected') + try: + data = json.loads(command(['docker', 'inspect', name])) + except (ValueError, TypeError): + raise Rejected('runtime_inspection_invalid') from None + require(isinstance(data, list) and len(data) == 1, 'runtime_inspection_invalid') + d = data[0] + require(d.get('Name') == '/' + name and d.get('State', {}).get('Running') is True, + 'container_not_running') + container_id, image_id = d.get('Id', ''), d.get('Image', '') + require(re.fullmatch(r'[a-f0-9]{64}', container_id), 'container_identity_invalid') + require(re.fullmatch(r'sha256:[a-f0-9]{64}', image_id), 'image_identity_invalid') + cfg = d.get('Config') or {} + env = cfg.get('Env') or [] + expected = 'true' if expected_enabled else 'false' + require(environment_values(env, 'DETAIL_GENERATOR_ENABLED') == [expected], 'runtime_flag_mismatch') + if expected_enabled: + require(environment_values(env, 'DETAIL_GENERATOR_STORAGE_DIR') == ['/app/detail-generator-private'], + 'runtime_storage_config_mismatch') + mounts = [m for m in d.get('Mounts', []) if m.get('Destination') == '/app/detail-generator-private'] + require(len(mounts) == 1, 'private_mount_missing_or_ambiguous') + mount = mounts[0] + require(mount.get('Source') == str(PRIVATE) and mount.get('Type') == 'bind' and mount.get('RW') is True, + 'private_mount_mismatch') + # Access check only. Never create/delete material files in a gate. + access = "require('fs').accessSync('/app/detail-generator-private',require('fs').constants.R_OK|require('fs').constants.W_OK)" + command(['docker', 'exec', '--user', '1001:1001', name, 'node', '-e', access]) + revision = (cfg.get('Labels') or {}).get('org.opencontainers.image.revision', '') + require(re.fullmatch(r'[a-f0-9]{40}', revision), 'revision_invalid') + require(command(['docker', 'exec', name, 'cat', '/app/DEPLOYMENT_COMMIT']).strip() == revision, + 'baked_revision_mismatch') + network = (d.get('NetworkSettings', {}).get('Networks') or {}).get(NETWORK) or {} + ip = network.get('IPAddress', '') + require(re.fullmatch(r'\d+\.\d+\.\d+\.\d+', ip), 'network_identity_invalid') + return {'container': name, 'containerId': container_id, 'imageId': image_id, + 'revision': revision, 'enabled': expected_enabled, 'privateIdentity': private_identity, + 'ip': ip} + + +def http_json(url): + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={'User-Agent': 'gongxiangp2-desktop-admission/1', 'Connection': 'close'}) + with opener.open(request, timeout=5) as response: + require(response.status == 200, 'candidate_config_unavailable') + return json.loads(response.read(16385)) + except Rejected: + raise + except Exception: + raise Rejected('candidate_config_unavailable') from None + + +def current_snapshot(): + physical(ENV_FILE) + enabled = enabled_from_env(ENV_FILE.read_text(encoding='utf-8-sig')) + identity = None + if enabled: + value = physical(PRIVATE, directory=True, owner=(1001, 1001), mode=0o700) + identity = [value.st_dev, value.st_ino, value.st_uid, value.st_gid] + physical(PROXY) + name, ip = config_target(PROXY.read_text(encoding='utf-8')) + value = inspect_target(name, enabled, identity) + require(value['ip'] == ip, 'proxy_network_mismatch') + config = http_json('http://' + ip + ':3000/api/desktop/config') + require(isinstance(config, dict) and config.get('enabled') is enabled + and config.get('clientId') == 'jiansun-detail-desktop' and config.get('pkce') == 'S256', + 'candidate_config_mismatch') + value.pop('ip') + return value + + +def policy_digest(): + return hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def receipt_path(snapshot): + return RECEIPTS / (snapshot['containerId'] + '.json') + + +def attest(snapshot, entry_sha): + require(re.fullmatch(r'[a-f0-9]{64}', entry_sha), 'entry_digest_invalid') + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + receipt = {'schema': VERSION, 'policySha256': policy_digest(), 'entrySha256': entry_sha, + 'verifiedAt': int(time.time()), 'snapshot': snapshot} + target = receipt_path(snapshot) + if target.exists(): + physical(target, owner=(0, 0), mode=0o600) + descriptor, temp = tempfile.mkstemp(prefix='.attest-', dir=RECEIPTS) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), 0o600) + json.dump(receipt, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + # Recheck identity after HTTP/filesystem observation, before publishing. + require(current_snapshot() == snapshot, 'candidate_changed_during_attestation') + os.replace(temp, target) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def validate_receipt(snapshot): + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + target = receipt_path(snapshot) + physical(target, owner=(0, 0), mode=0o600) + try: + receipt = json.loads(target.read_text(encoding='utf-8')) + except (OSError, ValueError): + raise Rejected('receipt_invalid') from None + require(receipt.get('schema') == VERSION and receipt.get('policySha256') == policy_digest() + and re.fullmatch(r'[a-f0-9]{64}', receipt.get('entrySha256', '')) + and receipt.get('snapshot') == snapshot, 'candidate_not_attested') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--attest-config', action='store_true') + parser.add_argument('--entry-sha256', default='') + args = parser.parse_args() + try: + require(os.geteuid() == 0, 'root_required') + snapshot = current_snapshot() + if args.attest_config: + attest(snapshot, args.entry_sha256) + else: + require(not args.entry_sha256, 'unexpected_entry_digest') + validate_receipt(snapshot) + print(json.dumps({'desktopAdmission': 'accepted', 'mode': 'attest' if args.attest_config else 'check', + 'containerId': snapshot['containerId'], 'revision': snapshot['revision'], + 'enabled': snapshot['enabled'], 'privateMountVerified': snapshot['enabled']})) + return 0 + except Rejected as error: + print(json.dumps({'desktopAdmission': 'rejected', 'reason': str(error)}), file=sys.stderr) + return 1 + except Exception: + # Docker output, env values, HTTP bodies and exception details can carry + # credentials. Never forward them to deployment logs or journald. + print(json.dumps({'desktopAdmission': 'rejected', 'reason': 'inspection_failed'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/desktop-release-admission.test.py b/scripts/desktop-release-admission.test.py new file mode 100644 index 00000000..d196ea79 --- /dev/null +++ b/scripts/desktop-release-admission.test.py @@ -0,0 +1,178 @@ +import ast +from contextlib import redirect_stderr, redirect_stdout +import copy +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).with_name('desktop-release-admission.py') +spec = importlib.util.spec_from_file_location('admission', SOURCE) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +def fixture(): + return {'Name': '/youyidian-3d-material-blue', 'Id': 'a' * 64, 'Image': 'sha256:' + 'b' * 64, + 'State': {'Running': True}, 'Config': {'Env': [ + 'DETAIL_GENERATOR_ENABLED=true', 'DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private', + 'DETAIL_GENERATOR_API_KEY=synthetic-never-print-this'], + 'Labels': {'org.opencontainers.image.revision': 'c' * 40}}, + 'Mounts': [{'Source': str(gate.PRIVATE), 'Destination': '/app/detail-generator-private', 'Type': 'bind', 'RW': True}], + 'NetworkSettings': {'Networks': {gate.NETWORK: {'IPAddress': '172.25.0.4'}}}} + + +class AdmissionTests(unittest.TestCase): + def test_approved_flag_is_explicit_and_unambiguous(self): + self.assertTrue(gate.enabled_from_env('\ufeff# heading\n DETAIL_GENERATOR_ENABLED=true\n')) + self.assertFalse(gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=false\r\n')) + for value in ['', 'true\nDETAIL_GENERATOR_ENABLED=false', '"true"', 'true # comment', 'TRUE', 'true ', '']: + text = '' if not value else 'DETAIL_GENERATOR_ENABLED=' + value + with self.subTest(value=value), self.assertRaises(gate.Rejected): + gate.enabled_from_env(text) + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED =true') + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=true\nDETAIL_GENERATOR_ENABLED=true') + + def test_exact_project_proxy_target_only(self): + text = ' server 172.25.0.4:3000 max_fails=3;\n add_header X-Material-Proxy-Target youyidian-3d-material-blue always;\n' + self.assertEqual(gate.config_target(text), ('youyidian-3d-material-blue', '172.25.0.4')) + for other in [text.replace('youyidian-3d-material-blue', 'other-project'), + text + 'server 172.25.0.5:3000 max_fails=3;\n', + text + 'add_header X-Material-Proxy-Target youyidian-3d-material-green always;\n', '']: + with self.subTest(other=other), self.assertRaises(gate.Rejected): + gate.config_target(other) + + def inspect(self, data): + calls = [] + def command(args): + calls.append(args) + if args[:2] == ['docker', 'inspect']: + return json.dumps([data]) + if args[-2:] == ['cat', '/app/DEPLOYMENT_COMMIT']: + return 'c' * 40 + return '' + with patch.object(gate, 'command', command): + result = gate.inspect_target('youyidian-3d-material-blue', True, [10, 20, 1001, 1001]) + return result, calls + + def test_valid_runtime_checks_access_without_creating_files(self): + result, calls = self.inspect(fixture()) + self.assertEqual(result['enabled'], True) + self.assertEqual(result['containerId'], 'a' * 64) + self.assertEqual(result['ip'], '172.25.0.4') + self.assertTrue(any('--user' in args and '1001:1001' in args for args in calls)) + self.assertNotIn('synthetic-never-print-this', json.dumps(result)) + self.assertFalse(any(any(x in args for x in ['create', 'run', 'start', 'rm', 'stop']) for args in calls)) + + def test_reproduced_incident_rejected(self): + data = fixture() + data['Config']['Env'][0] = 'DETAIL_GENERATOR_ENABLED=false' + data['Mounts'] = [] + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_every_mount_boundary_is_enforced(self): + cases = [([], 'missing_or_ambiguous'), + ([{**fixture()['Mounts'][0], 'Source': '/other-project/private'}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'RW': False}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'Type': 'volume'}], 'mount_mismatch'), + (fixture()['Mounts'] * 2, 'missing_or_ambiguous')] + for mounts, reason in cases: + data = fixture() + data['Mounts'] = mounts + with self.subTest(mounts=mounts), self.assertRaisesRegex(gate.Rejected, reason): + self.inspect(data) + + def test_duplicate_runtime_flags_never_shadow_approved_setting(self): + data = fixture() + data['Config']['Env'].append('DETAIL_GENERATOR_ENABLED=false') + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_baked_identity_and_network_cannot_be_substituted(self): + for field, value, code in [('Id', 'bad', 'container_identity_invalid'), + ('Image', 'image:latest', 'image_identity_invalid'), + ('Name', '/other-project', 'container_not_running')]: + data = fixture() + data[field] = value + with self.subTest(field=field), self.assertRaisesRegex(gate.Rejected, code): + self.inspect(data) + data = fixture() + data['NetworkSettings']['Networks'] = {'other-network': {'IPAddress': '172.25.0.4'}} + with self.assertRaisesRegex(gate.Rejected, 'network_identity_invalid'): + self.inspect(data) + + def test_missing_receipt_blocks_legacy_entry(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', side_effect=lambda p, **kw: p.stat()): + with self.assertRaises(FileNotFoundError): + gate.validate_receipt(snapshot) + + def test_receipt_is_bound_to_every_current_container_fact(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True, 'imageId': 'sha256:' + 'b' * 64, + 'privateIdentity': [10, 20, 1001, 1001]} + receipt = {'schema': 1, 'policySha256': gate.policy_digest(), 'entrySha256': 'd' * 64, 'snapshot': snapshot} + path = root / ('a' * 64 + '.json') + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None): + path.write_text(json.dumps(receipt)) + gate.validate_receipt(snapshot) + for field, value in [('schema', 2), ('policySha256', 'e' * 64), ('entrySha256', '')]: + changed = {**receipt, field: value} + path.write_text(json.dumps(changed)) + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(snapshot) + path.write_text(json.dumps(receipt)) + for field, value in [('enabled', False), ('imageId', 'sha256:' + 'e' * 64), ('privateIdentity', [10, 99, 1001, 1001])]: + changed = {**snapshot, field: value} + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(changed) + + def test_attestation_rechecks_identity_before_atomic_publication(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None), \ + patch.object(gate.os, 'fchmod', create=True), patch.object(gate.os, 'fchown', create=True), \ + patch.object(gate, 'current_snapshot', return_value={**snapshot, 'enabled': False}): + with self.assertRaisesRegex(gate.Rejected, 'candidate_changed_during_attestation'): + gate.attest(snapshot, 'd' * 64) + self.assertEqual(list(root.iterdir()), []) + + def test_inspection_failures_do_not_echo_secrets(self): + error_output = io.StringIO() + with patch.object(gate.sys, 'argv', ['guard.py']), patch.object(gate.os, 'geteuid', return_value=0, create=True), \ + patch.object(gate, 'current_snapshot', side_effect=ValueError('synthetic-never-print-this')), redirect_stderr(error_output): + self.assertEqual(gate.main(), 1) + self.assertNotIn('synthetic-never-print-this', error_output.getvalue()) + self.assertIn('inspection_failed', error_output.getvalue()) + + def test_installer_only_hooks_project_start_and_graceful_reload(self): + text = SOURCE.with_name('install-desktop-release-admission.py').read_text(encoding='utf-8') + tree = ast.parse(text) + names = {'BASE', 'POLICY', 'GUARD', 'UNIT', 'UNIT_FILE', 'DROPIN', 'PROXY'} + statements = [n for n in tree.body if (isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name) and n.targets[0].id in names) + or (isinstance(n, ast.FunctionDef) and n.name == 'dropin_text')] + scope = {'Path': Path} + exec(compile(ast.Module(body=statements, type_ignores=[]), '', 'exec'), scope) + dropin = scope['dropin_text']() + self.assertIn('ExecStartPre=/usr/bin/python3', dropin) + self.assertIn('ExecReload=\nExecReload=/usr/bin/python3', dropin) + self.assertLess(dropin.index('ExecReload=/usr/bin/python3'), dropin.index('ExecReload=/usr/sbin/nginx')) + self.assertNotIn("'restart'", text) + self.assertNotIn('docker stop', text) + self.assertIn('proxyPidUnchanged', text) + self.assertIn('os.fchown(stream.fileno(), 0, 0)', text) + self.assertIn('os.chown(path, 0, 0)', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/export-material-preview-health-manifest.mjs b/scripts/export-material-preview-health-manifest.mjs new file mode 100644 index 00000000..c37a2cc1 --- /dev/null +++ b/scripts/export-material-preview-health-manifest.mjs @@ -0,0 +1,141 @@ +// Read-only inventory of every active Material.previewUrl, not HISTORY_IMPORT. +// Public-source probes remain separate; an uncheckable row is never omitted or healthy. +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { LIMITS, registeredSource } from './check-material-preview-health.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const sha256 = value => createHash('sha256').update(value).digest('hex') +const runtimeRequire = createRequire(import.meta.url) +export class InventoryError extends Error { + constructor(code) { super(code); this.code = code } +} +function optionsChecked({ pageSize = 500, batchSize = LIMITS.materials, baselineHost, baselineCount } = {}) { + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1000 + || !Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > LIMITS.materials) throw new InventoryError('invalid_batch_budget') + if ((baselineHost === undefined) !== (baselineCount === undefined) + || (baselineHost !== undefined && (!LEGACY_DOMAINS.includes(baselineHost) || !Number.isSafeInteger(baselineCount) || baselineCount < 0))) { + throw new InventoryError('invalid_legacy_baseline') + } + return { pageSize, batchSize, baselineHost, baselineCount } +} + +export async function collectMaterialPreviewInventory(prisma, options = {}) { + const { pageSize, batchSize, baselineHost, baselineCount } = optionsChecked(options) + if (typeof prisma?.$transaction !== 'function') throw new InventoryError('database_reader_required') + return prisma.$transaction(async tx => { + await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY') + const activeCount = await tx.material.count({ where: { deletedAt: null } }) + if (!Number.isSafeInteger(activeCount) || activeCount < 0) throw new InventoryError('invalid_active_count') + const materials = [], uncheckable = [], seen = new Set(), materialTypeCounts = Object.create(null), legacyHostCounts = Object.fromEntries(LEGACY_DOMAINS.map(h => [h, 0])) + const inventoryDigest = createHash('sha256') + let cursor, pages = 0 + while (true) { + const rows = await tx.material.findMany({ + where: { deletedAt: null }, select: { id: true, materialType: true, previewUrl: true }, + orderBy: { id: 'asc' }, take: pageSize, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }) + pages++ + if (!Array.isArray(rows) || rows.length > pageSize) throw new InventoryError('invalid_inventory_page') + if (!rows.length) break + for (const row of rows) { + if (typeof row.id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(row.id) || seen.has(row.id)) throw new InventoryError('duplicate_or_invalid_inventory_identity') + seen.add(row.id) + if (seen.size > activeCount) throw new InventoryError('inventory_count_mismatch') + const type = typeof row.materialType === 'string' ? row.materialType : '' + materialTypeCounts[type] = (materialTypeCounts[type] || 0) + 1 + const source = row.previewUrl + inventoryDigest.update(JSON.stringify([row.id, source ?? null]) + '\n') + if (typeof source === 'string') { + for (const host of LEGACY_DOMAINS) { + if ([`https://${host}/`, `http://${host}/`].some(prefix => source.toLowerCase().startsWith(prefix))) legacyHostCounts[host]++ + } + } + try { + const url = registeredSource(source) + materials.push({ materialId: row.id, registeredSource: url.href }) + } catch (error) { + uncheckable.push({ materialId: row.id, materialType: type, sourceSha256: sha256(typeof source === 'string' ? source : ''), + classification: error?.classification || 'source_rejected', code: error?.code || 'invalid_url' }) + } + } + const next = rows.at(-1).id + if (next === cursor) throw new InventoryError('inventory_cursor_stalled') + cursor = next + } + if (seen.size !== activeCount || materials.length + uncheckable.length !== activeCount) throw new InventoryError('inventory_count_mismatch') + const manifests = [] + for (let i = 0; i < materials.length; i += batchSize) { + manifests.push({ version: 1, reviewed: true, provenance: 'read-only-active-material-inventory', materials: materials.slice(i, i + batchSize) }) + } + const audit = { version: 1, readOnly: true, capturedAt: new Date().toISOString(), inventoryComplete: true, + scope: { table: 'Material', where: { deletedAt: null }, historyImportFilter: false }, + transaction: { isolationLevel: 'RepeatableRead', readOnly: true }, + activeCount, exportedCount: materials.length, uncheckableCount: uncheckable.length, materialTypeCounts, legacyHostCounts, + ...(baselineHost === undefined ? {} : { legacyBaseline: { host: baselineHost, expectedCount: baselineCount, actualCount: legacyHostCounts[baselineHost], + delta: legacyHostCounts[baselineHost] - baselineCount, matches: legacyHostCounts[baselineHost] === baselineCount } }), + inventorySha256: inventoryDigest.digest('hex'), pages, batchSize, batchCount: manifests.length, uncheckable, + readyForHealthChecks: uncheckable.length === 0, healthChecksExecuted: false } + return { audit, manifests } + }, { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }) +} + +export async function writeInventoryArtifacts(outputDirectory, inventory, { fsImpl = fs } = {}) { + if (typeof outputDirectory !== 'string' || !path.isAbsolute(outputDirectory)) throw new InventoryError('absolute_new_output_directory_required') + if (!inventory?.audit?.inventoryComplete || !Array.isArray(inventory.manifests) + || inventory.audit.batchCount !== inventory.manifests.length) throw new InventoryError('complete_inventory_required') + const output = path.resolve(outputDirectory) + // A fresh directory and exclusive files prevent stale acceptance or accidental overwrite. + await fsImpl.mkdir(output, { mode: 0o700, recursive: false }) + const batches = [] + for (let i = 0; i < inventory.manifests.length; i++) { + const file = `manifest-${String(i + 1).padStart(6, '0')}.json` + const raw = JSON.stringify(inventory.manifests[i], null, 2) + '\n' + await fsImpl.writeFile(path.join(output, file), raw, { flag: 'wx', mode: 0o600 }) + batches.push({ file, sha256: sha256(raw), manifestSha256: sha256(JSON.stringify(inventory.manifests[i])), materialCount: inventory.manifests[i].materials.length }) + } + const audit = { ...inventory.audit, batches } + // Written last: incomplete directory contents are never a complete inventory receipt. + await fsImpl.writeFile(path.join(output, 'audit.json'), JSON.stringify(audit, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) + return { outputDirectory: output, audit } +} + +export async function main(args = process.argv.slice(2), { prismaClient, fsImpl = fs } = {}) { + const options = {} + for (let i = 0; i < args.length; i += 2) { + if (!['--output-dir', '--baseline-host', '--baseline-count'].includes(args[i]) || args[i + 1] === undefined || options[args[i]] !== undefined) { + throw new InventoryError('usage_output_directory_required') + } + options[args[i]] = args[i + 1] + } + if (!options['--output-dir']) throw new InventoryError('usage_output_directory_required') + if (!path.isAbsolute(options['--output-dir'])) throw new InventoryError('absolute_new_output_directory_required') + const baseline = options['--baseline-host'] === undefined && options['--baseline-count'] === undefined ? {} : { + baselineHost: options['--baseline-host'], baselineCount: Number(options['--baseline-count']), + } + optionsChecked(baseline) + const owned = !prismaClient + const prisma = prismaClient || new (runtimeRequire('@prisma/client').PrismaClient)({ log: [] }) + try { + const inventory = await collectMaterialPreviewInventory(prisma, baseline) + return await writeInventoryArtifacts(options['--output-dir'], inventory, { fsImpl }) + } finally { + if (owned) await prisma.$disconnect() + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(({ outputDirectory, audit }) => { + console.log(JSON.stringify({ version: 1, readOnly: true, outputDirectory, inventoryComplete: audit.inventoryComplete, + activeCount: audit.activeCount, exportedCount: audit.exportedCount, uncheckableCount: audit.uncheckableCount, + batchCount: audit.batchCount, legacyHostCounts: audit.legacyHostCounts, legacyBaseline: audit.legacyBaseline, healthChecksExecuted: false }, null, 2)) + process.exitCode = audit.readyForHealthChecks ? 0 : 1 + }, error => { + console.log(JSON.stringify({ version: 1, readOnly: true, inventoryComplete: false, + code: error instanceof InventoryError ? error.code : 'inventory_export_failed' }, null, 2)) + process.exitCode = 1 + }) +} diff --git a/scripts/export-material-preview-health-manifest.test.mjs b/scripts/export-material-preview-health-manifest.test.mjs new file mode 100644 index 00000000..981a3e2f --- /dev/null +++ b/scripts/export-material-preview-health-manifest.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' +import { collectMaterialPreviewInventory, main, writeInventoryArtifacts } from './export-material-preview-health-manifest.mjs' +import { validateManifest } from './check-material-preview-health.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const current = id => `https://s3.jiansun.vip/gongxiangpan/display/${id}.webp` +const row = (id, previewUrl = current(id), materialType = '材质') => ({ id, previewUrl, materialType }) +const hash = value => createHash('sha256').update(value).digest('hex') + +function database(rows, { reportedCount = rows.length, findMany } = {}) { + const events = [], reads = [] + const prisma = { + async $transaction(fn, options) { + events.push(['transaction', options]) + return fn({ + async $executeRawUnsafe(sql) { events.push(['sql', sql]) }, + material: { + async count(query) { events.push(['count', query]); return reportedCount }, + async findMany(query) { + reads.push(query) + events.push(['page']) + if (findMany) return findMany(query, reads.length) + const start = query.cursor ? rows.findIndex(item => item.id === query.cursor.id) + query.skip : 0 + return rows.slice(start, start + query.take) + }, + }, + }) + }, + async $disconnect() { events.push(['disconnect']) }, + } + return { prisma, events, reads } +} + +function memoryFs({ failFile } = {}) { + const writes = [], directories = new Set() + return { + writes, + async mkdir(directory, options) { + assert.deepEqual(options, { mode: 0o700, recursive: false }) + if (directories.has(directory)) throw new Error('EEXIST') + directories.add(directory) + }, + async writeFile(file, raw, options) { + assert.deepEqual(options, { flag: 'wx', mode: 0o600 }) + if (file.endsWith(failFile || '\0')) throw new Error('simulated write failure') + writes.push({ file, raw }) + }, + } +} + +test('all active rows are read in a consistent read-only snapshot, with no HISTORY_IMPORT filter', async () => { + const db = database(Array.from({ length: 103 }, (_, i) => row(`m-${String(i).padStart(3, '0')}`))) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma, { pageSize: 25 }) + assert.deepEqual(db.events[0], ['transaction', { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }]) + assert.deepEqual(db.events[1], ['sql', 'SET TRANSACTION READ ONLY']) + assert.deepEqual(db.events[2], ['count', { where: { deletedAt: null } }]) + for (const query of db.reads) { + assert.deepEqual(query.where, { deletedAt: null }) + assert.deepEqual(query.select, { id: true, materialType: true, previewUrl: true }) + assert.deepEqual(query.orderBy, { id: 'asc' }) + assert.equal(query.take, 25) + assert.ok(!JSON.stringify(query).includes('HISTORY_IMPORT')) + } + assert.deepEqual(db.reads[1].cursor, { id: 'm-024' }) + assert.equal(db.reads[1].skip, 1) + assert.equal(audit.activeCount, 103) + assert.equal(audit.exportedCount, 103) + assert.equal(audit.uncheckableCount, 0) + assert.equal(audit.pages, 6, 'the final empty page proves pagination is exhausted') + assert.equal(audit.inventoryComplete, true) + assert.equal(audit.healthChecksExecuted, false) + assert.equal(audit.materialTypeCounts['材质'], 103) + assert.deepEqual(manifests.map(item => item.materials.length), [50, 50, 3]) + assert.equal(new Set(manifests.flatMap(item => validateManifest(item).map(entry => entry.materialId))).size, 103) +}) + +test('missing and unregistered sources remain counted and cannot become a full health pass', async () => { + const secret = 'do-not-print-signed-query' + const db = database([row('one'), row('two', null), row('three', '/uploads/old.webp'), + row('four', `https://unregistered.example/image.webp?signature=${secret}`, '__proto__')]) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma) + assert.equal(audit.activeCount, 4) + assert.equal(audit.exportedCount + audit.uncheckableCount, audit.activeCount) + assert.equal(audit.uncheckableCount, 3) + assert.equal(audit.readyForHealthChecks, false) + assert.equal(audit.materialTypeCounts.__proto__, 1) + assert.deepEqual(manifests.flatMap(item => item.materials).map(item => item.materialId), ['one']) + assert.deepEqual(audit.uncheckable.map(item => item.materialId), ['two', 'three', 'four']) + assert.ok(!JSON.stringify(audit).includes(secret)) + assert.ok(audit.uncheckable.every(item => /^[a-f0-9]{64}$/.test(item.sourceSha256))) +}) + +test('legacy baseline is exact and reports drift rather than reusing 483 as current truth', async () => { + const rows = Array.from({ length: 483 }, (_, i) => row(`legacy-${i}`, `https://s3.1080p.hk/jsxiangche/${i}.webp`)) + rows.push(row('different-host', 'https://s3.yydd.ai/gongxiangpan/a.webp'), row('new-source')) + const db = database(rows) + const inventory = await collectMaterialPreviewInventory(db.prisma, { baselineHost: 's3.1080p.hk', baselineCount: 483 }) + assert.deepEqual(inventory.audit.legacyBaseline, { host: 's3.1080p.hk', expectedCount: 483, actualCount: 483, delta: 0, matches: true }) + assert.equal(inventory.audit.activeCount, 485) + assert.equal(inventory.audit.batchCount, 10) + const changed = await collectMaterialPreviewInventory(database(rows.slice(1)).prisma, { baselineHost: 's3.1080p.hk', baselineCount: 483 }) + assert.equal(changed.audit.legacyBaseline.actualCount, 482) + assert.equal(changed.audit.legacyBaseline.matches, false) + assert.equal(changed.audit.legacyBaseline.delta, -1) +}) + +test('duplicates, incomplete counts, invalid pages and changed pagination fail closed', async () => { + await assert.rejects(collectMaterialPreviewInventory(database([row('same'), row('same')]).prisma), { code: 'duplicate_or_invalid_inventory_identity' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one')], { reportedCount: 2 }).prisma), { code: 'inventory_count_mismatch' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one')], { reportedCount: 0 }).prisma), { code: 'inventory_count_mismatch' }) + await assert.rejects(collectMaterialPreviewInventory(database([], { findMany: () => null }).prisma), { code: 'invalid_inventory_page' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one'), row('two')], { findMany: () => [row('one'), row('two')] }).prisma, { pageSize: 1 }), { code: 'invalid_inventory_page' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one'), row('two')], { findMany: () => [row('one')] }).prisma, { pageSize: 1 }), { code: 'duplicate_or_invalid_inventory_identity' }) +}) + +test('empty inventory is explicit and invalid limits cause no database read', async () => { + const db = database([]) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma) + assert.equal(audit.activeCount, 0) + assert.equal(audit.inventoryComplete, true) + assert.deepEqual(manifests, []) + const other = database([]) + for (const options of [{ batchSize: 51 }, { pageSize: 1001 }, { baselineCount: 483 }, { baselineHost: 'evil.example', baselineCount: 1 }]) { + await assert.rejects(collectMaterialPreviewInventory(other.prisma, options)) + } + assert.equal(other.events.length, 0) +}) + +test('fresh private files contain all batches and audit is written last with both hash identities', async () => { + const inventory = await collectMaterialPreviewInventory(database([row('one'), row('two')]).prisma, { batchSize: 1 }) + const fsImpl = memoryFs() + const directory = path.resolve('test-output-not-created') + const { audit } = await writeInventoryArtifacts(directory, inventory, { fsImpl }) + assert.deepEqual(fsImpl.writes.map(item => path.basename(item.file)), ['manifest-000001.json', 'manifest-000002.json', 'audit.json']) + for (let i = 0; i < 2; i++) { + assert.equal(audit.batches[i].sha256, hash(fsImpl.writes[i].raw)) + assert.equal(audit.batches[i].manifestSha256, hash(JSON.stringify(inventory.manifests[i]))) + assert.equal(audit.batches[i].materialCount, 1) + } + await assert.rejects(writeInventoryArtifacts(directory, inventory, { fsImpl }), /EEXIST/) + await assert.rejects(writeInventoryArtifacts('relative-output', inventory, { fsImpl }), { code: 'absolute_new_output_directory_required' }) +}) + +test('partial disk failure cannot leave a complete audit receipt or overwrite another run', async () => { + const inventory = await collectMaterialPreviewInventory(database([row('one'), row('two')]).prisma, { batchSize: 1 }) + const fsImpl = memoryFs({ failFile: 'manifest-000002.json' }) + await assert.rejects(writeInventoryArtifacts(path.resolve('not-created'), inventory, { fsImpl }), /simulated write failure/) + assert.equal(fsImpl.writes.length, 1) + assert.ok(!fsImpl.writes.some(item => item.file.endsWith('audit.json'))) +}) + +test('CLI supports injected runtime dependencies and rejects bad arguments before database access', async () => { + const db = database([row('one')]) + const fsImpl = memoryFs() + const result = await main(['--output-dir', path.resolve('not-created'), '--baseline-host', 's3.1080p.hk', '--baseline-count', '483'], { prismaClient: db.prisma, fsImpl }) + assert.equal(result.audit.activeCount, 1) + assert.equal(result.audit.legacyBaseline.matches, false) + assert.ok(!db.events.some(item => item[0] === 'disconnect'), 'an injected Prisma client is not owned by the CLI') + const before = db.events.length + await assert.rejects(main(['--output-dir', 'relative'], { prismaClient: db.prisma, fsImpl }), { code: 'absolute_new_output_directory_required' }) + await assert.rejects(main(['--output-dir', path.resolve('not-created'), '--batch-size', '500'], { prismaClient: db.prisma, fsImpl }), { code: 'usage_output_directory_required' }) + assert.equal(db.events.length, before) +}) + +test('stdin adapter resolves runtime CJS dependencies without deployed script files or a database connection', async () => { + const data = source => 'data:text/javascript;base64,' + Buffer.from(source).toString('base64') + const runtimeBase = pathToFileURL(path.resolve('package.json')).href + const adapt = source => source.replaceAll('import.meta.url', JSON.stringify(runtimeBase)) + const registry = data(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) + const health = data(adapt(await readFile(new URL('./check-material-preview-health.mjs', import.meta.url), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))) + const exporter = data(adapt(await readFile(new URL('./export-material-preview-health-manifest.mjs', import.meta.url), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)) + .replace("'./check-material-preview-health.mjs'", JSON.stringify(health))) + const script = `import {createRequire} from 'node:module'; + const {collectMaterialPreviewInventory}=await import(${JSON.stringify(exporter)}); + const {checkMaterialPreviewHealth}=await import(${JSON.stringify(health)}); + const sharp=createRequire(${JSON.stringify(runtimeBase)})('sharp'); + const preview=await sharp({create:{width:3,height:2,channels:3,background:'#345678'}}).webp().toBuffer(); + let pages=0; + const inventory=await collectMaterialPreviewInventory({$transaction:async fn=>fn({ + $executeRawUnsafe:async sql=>{if(sql!=='SET TRANSACTION READ ONLY')throw Error('unexpected SQL')}, + material:{count:async()=>1,findMany:async()=>++pages===1?[{id:'one',materialType:'材质',previewUrl:${JSON.stringify(current('one'))}}]:[]} + })}); + const result=await checkMaterialPreviewHealth(inventory.manifests[0],{fetchImpl:async()=>new Response(preview,{headers:{'content-type':'image/webp'}})}); + console.log(JSON.stringify({count:inventory.audit.activeCount,passed:result.passed,decoded:result.results[0].decoded}));` + const child = spawnSync(process.execPath, ['--input-type=module', '-'], { input: script, encoding: 'utf8', timeout: 10_000 }) + assert.equal(child.status, 0, child.stderr) + assert.deepEqual(JSON.parse(child.stdout), { count: 1, passed: true, decoded: { width: 3, height: 2 } }) +}) diff --git a/scripts/fixtures/material-preview-known-legacy-20261023.json b/scripts/fixtures/material-preview-known-legacy-20261023.json new file mode 100644 index 00000000..88aa6795 --- /dev/null +++ b/scripts/fixtures/material-preview-known-legacy-20261023.json @@ -0,0 +1,1896 @@ +{ + "version": 1, + "incident": "legacy-preview-20261023", + "reviewedAuditSha256": "40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d", + "membersSha256": "953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c", + "members": [ + { + "materialId": "01331013-f5ca-4f07-8ab4-32b0035d91c3", + "sourceSha256": "56c05f59bb492ca82b79ca6942e947048543b9e2aa006b4fca0c9e51067d0c79" + }, + { + "materialId": "0243d445-dff7-4b12-9e7d-d6e94f13af3b", + "sourceSha256": "42007b6a0df1eea9c472b50950c96485309cc17ee94a46b520c2359f687f4d28" + }, + { + "materialId": "025d5822-e096-490d-8100-0bd18055d738", + "sourceSha256": "505d65b20cb53fdf4bedc91ef139f41d9492e2982df0c93075b417f72d942681" + }, + { + "materialId": "0387680a-a003-4f51-ba6d-f9f3279d33b9", + "sourceSha256": "c6fc5987efba89e7e193101b92d4bdf0697891d4df9da3401b69c10ffb229ed0" + }, + { + "materialId": "03a2dfc4-5a90-4577-8dcc-b6f5ac6d0824", + "sourceSha256": "aee709896dd59513bf110ca04bc9e12ed81018d0be542edcfafae2993229d81e" + }, + { + "materialId": "04685872-18c8-4f70-80b6-eab734811de1", + "sourceSha256": "12b030a83e890c512a5fb89b124c0f233b076214e9309904e407b130f76d3a5d" + }, + { + "materialId": "05826144-065a-40ba-97f7-d830933981bb", + "sourceSha256": "0fbe08e5829f32826837081723abf60e949d92c9ce0fa7b88d7ecc07953f61fe" + }, + { + "materialId": "06e3269a-531a-4761-8ef6-f414a1fad4e0", + "sourceSha256": "e93d9d89dc71704390bd313aa16d7dd0d35a797481d771168cbdde6fb1a152ff" + }, + { + "materialId": "0799cdab-6ffb-4bc0-b435-9d93811dc321", + "sourceSha256": "c518014181ba6a3c83ae4688f818ce61d137b091da6bc6b841ef6a1500edc5f3" + }, + { + "materialId": "08acbe0d-9078-4f42-ab57-00628c39c423", + "sourceSha256": "f10a40eaaca942f2416d5ea13fc05fc206ab8186a823917a2c2ea76e3d75484f" + }, + { + "materialId": "08b31dae-61a1-4e50-9936-4c08238c8ffb", + "sourceSha256": "ffd698ca8370eb93b2ab236e56ae2bb860740b9711d7c2c246b11ed15d2ce221" + }, + { + "materialId": "091312be-55cc-4f64-aa2b-9b6401b6a875", + "sourceSha256": "729e715c3aad6b05823811e8598bf8dfbbe33e98cafc550d9867e727b2d4e7d0" + }, + { + "materialId": "097d2759-81c9-42d0-b367-551f74244c35", + "sourceSha256": "21e065483a12e3d3bd0d8184969cd8795e792acb0ae892b17058d8cae7ee9c1d" + }, + { + "materialId": "09d2043c-2bf4-4f1b-a173-34c874d6552d", + "sourceSha256": "8fcc827b0ea89235534147da76f825d431903ae3b207da3474aafd724707936c" + }, + { + "materialId": "0a027e49-ce6a-47ae-ab52-847f3893e379", + "sourceSha256": "43e581e249f1cb5475e981284f1930adef0d8588a51e30001cf9aa41a43cf019" + }, + { + "materialId": "0a224cee-962c-4b12-af3f-2623ebd0770f", + "sourceSha256": "c7fc8c5d46d02f4f7e559979a989daa61ab9075e3a522799df5e07deb8160442" + }, + { + "materialId": "0a6f4d0f-db03-4498-8dde-b9de1d84a3c4", + "sourceSha256": "96630346cfdf92a69188b1f82b127b97b932b3161a945e5da6a7354f8acd4cc4" + }, + { + "materialId": "0aa9fadf-76f5-4882-a9c8-617b441516db", + "sourceSha256": "337f861faf18167a2b17732be9769b3b218862857d27fd1e58040350a31698c5" + }, + { + "materialId": "0b3d768d-3225-4d63-8e81-87d2ebfb4c97", + "sourceSha256": "8cc7a7aafdf644922ae230a1aa70953457b058bb1f27120fd95d382255e983ea" + }, + { + "materialId": "0bb370e3-7ad4-495a-95c7-92292c455bda", + "sourceSha256": "d7204cf7c41784d62227b4146f5b218c72662cc192b33811e5768610e638de7b" + }, + { + "materialId": "0cb34204-036f-4af3-881d-09ff3eb54013", + "sourceSha256": "59d53184f84bdabc06a2aa898ee058ad39d285a278d7fb3af9b5115b9e0d835f" + }, + { + "materialId": "0cd7ba10-9ff4-4d75-87c0-87b552364e2b", + "sourceSha256": "291fc1385b43c815672f11859b64123700bab826622da6a3b32b20015a6c9aeb" + }, + { + "materialId": "0cdf0b90-b633-4894-a690-52a1c4b6874d", + "sourceSha256": "7ffdd25285fb0aad929183c31ee867402156d546c7b4a6eeb280e6fd6fe6e71e" + }, + { + "materialId": "0d18747f-3cb0-4170-a12d-6049d990d0e6", + "sourceSha256": "e4c1da0550d7fa7a2f07aea6ac41f1f988d52fe7200f1b48c03bf03b9fb8befc" + }, + { + "materialId": "0d1f1c2c-25ba-4fa3-a1c9-33c775fbce65", + "sourceSha256": "033bffc2e0111dc670bafe4f028901d6b2bf222d79540175b998823b28ac8131" + }, + { + "materialId": "0df159cb-e9f8-4d5d-b962-4ed7240402a6", + "sourceSha256": "13edf260e8e0b1131c300e4d8dc79d7896b464b375eb18146a4afadef62ed0d1" + }, + { + "materialId": "0dff1328-ef4a-4816-8063-b87ce5029ad2", + "sourceSha256": "07307bd9a2b37e0e38cade9dfe5bb1d851f3f5e92cac48fceb892fa16798e66e" + }, + { + "materialId": "0e7681c0-5aa9-4659-8582-9f2bc5995bcb", + "sourceSha256": "f2827860475fe6d77c5cf051e69e669108bd3d6a9df22e884332605eb6490eb9" + }, + { + "materialId": "0f018c02-4cfe-4a53-ab8d-c033d4a84a82", + "sourceSha256": "27d9106bf29187fa347a9c968391ea96acf4208d37466acd76b8e048b9c8abe5" + }, + { + "materialId": "0f64eb5f-2740-45b3-990b-69fba35cc66d", + "sourceSha256": "4dd7fea55d3056a9f870b1c2f8b60204ced105dabfff0b36d2eccd49f2187dd3" + }, + { + "materialId": "0f8783c5-1b57-4fd3-bd49-6bfd029e4667", + "sourceSha256": "147b170520e49c4c40c2985c5b6576bc10c1294f9c95567a772f240f2d0707f6" + }, + { + "materialId": "0fc15479-1b12-406a-b4ce-9d4d6dc8a0c8", + "sourceSha256": "0657866896fd48ad554bf3f3ce1ce7c0842fa5dbe586cd6b094aadcd9cf888bf" + }, + { + "materialId": "109ee131-f96e-45b0-bd8e-a2cbc1503d58", + "sourceSha256": "104870d10261949cb3cf2dc532653572bca5a05e20a55077b7f0f1b2ad9ab1a6" + }, + { + "materialId": "10cd39b4-82d8-4644-9c60-28c1043cdc8e", + "sourceSha256": "b28acab0808527363061068557f045caab602aac0f93ffcc35373a341e747f91" + }, + { + "materialId": "10f0dac0-21b5-4b8d-a980-7ead0d0ce1d4", + "sourceSha256": "322410a138087e2a1b9d1b111f8b68630cd3f0a38dad89dcbe03c90e4bca2ef8" + }, + { + "materialId": "11191a17-da31-426d-9107-5ed60d776daf", + "sourceSha256": "8a0a41511832c5c7fd3c39178ae0808bec791fc6a2d639d1c88129e0bce5e986" + }, + { + "materialId": "12cb64a5-2651-429e-bce3-167f8ef6fe2c", + "sourceSha256": "d62ae94f0828aff5d12a3fb35299e18680317afe75884b38dd4b915f77302360" + }, + { + "materialId": "1330e10d-da0d-4f53-b37c-b649d8af9eea", + "sourceSha256": "3fed3451042e517b94c0573e14b56ca8aca48346d854e78ef6f0ea419bc4c2a6" + }, + { + "materialId": "13587237-ac9f-4be2-8a89-3c1116799dbe", + "sourceSha256": "12690d90c5f0925a59cb36d7dcbed1824e90e42ece996fcb63a573a7057141d8" + }, + { + "materialId": "13c2aed5-e197-421c-8fe3-24486506f5aa", + "sourceSha256": "555e73e8ce8831627de804e175d39ae8f348539f5ff9413d6eb270e150f6ad88" + }, + { + "materialId": "13ded73e-29e3-417a-bc6a-4f34bdc9c1b2", + "sourceSha256": "1a423fe391b20428e012de9966a11de0e5aa6b9b1bc11b9a3f932b58673822f1" + }, + { + "materialId": "14482941-1d0d-446b-b339-6d825b9eba94", + "sourceSha256": "a39c31b25c1272746d863c9a93b50340c2a0f86448ebb4f405e26dc447d4b49e" + }, + { + "materialId": "14f4ffe9-73a5-4a0c-a0f3-c08f8b56086a", + "sourceSha256": "05f65b860d7262515001caf04076d4b0ea49f4d64e7b552b95663c2d980764c7" + }, + { + "materialId": "1535871f-4484-41d5-b145-45f76ade6c5e", + "sourceSha256": "f507bf8b04df1cba379a8e83c6776a9681c1bde7807738ac212a1ad29b98d393" + }, + { + "materialId": "158bec33-f316-4981-9dc1-67996cab5d12", + "sourceSha256": "c00ace534e3f8be9ff7912c0f917890586c2839db015f9ef4211894067f35602" + }, + { + "materialId": "16f870d3-8ac4-4538-af9a-0d74b2d15bbf", + "sourceSha256": "cdbab9373ed6bddb8b5aef9bdb5ac1c21644d4a974ef9197aa5a461930f94a0e" + }, + { + "materialId": "1720f168-39d4-4ebd-bc45-9346570ebd1c", + "sourceSha256": "1554acb2891681fddee066dca3f306fe56aa09d445201e719d4dd0c41fa6f42a" + }, + { + "materialId": "19abf7e7-a8e6-4cd5-bdff-8df531c434e9", + "sourceSha256": "1fc90d5af303cf3222d9cce3828da3229d133eab03d28e9ab2d013dc9562e04b" + }, + { + "materialId": "1b3d0235-f3e1-4280-9963-d78983c4e93e", + "sourceSha256": "de1d5276f795718e3ac35bd978dcbf059844da37e66f2416af32f4bb1b37f63f" + }, + { + "materialId": "1c7ff3bb-2cb4-449b-8866-3604b0c8c5d7", + "sourceSha256": "6c8512865ff36c59aab911c30ae8f1e9e71f6d5fa347c1d3194a2c2a96ebe9c3" + }, + { + "materialId": "1c845b4e-40ee-44de-acae-99d9cec50c98", + "sourceSha256": "edc3083b77c8b2bb44fa82a8465d6a983e66715ace1b3843ce05d02e146184fc" + }, + { + "materialId": "1d420d79-fb1a-471e-8048-2e37a9fdcd07", + "sourceSha256": "452cba4f335afbbc980f693ba1537a921717f1bc228b0fdea933dec7623f3553" + }, + { + "materialId": "1dba95a0-62b4-4001-b65d-8087154e0fd7", + "sourceSha256": "ccef1f37b3a18152993a73c940278cb3528b869514b4f189032cdc7aba9feefe" + }, + { + "materialId": "1e710c41-4f92-4c31-8805-ee855702fddf", + "sourceSha256": "a12386d350621cb1a1c4ec433bee2fc780231be3c8df615bbf97a3a105411fd0" + }, + { + "materialId": "1fd77009-8197-45e7-ba22-d43c64da6411", + "sourceSha256": "b45af361b02185431ba5554f24de2ff68e8b095e447a282b898346117ac7a56b" + }, + { + "materialId": "20f120ca-af2d-4b57-b9ba-2ec3d4dae527", + "sourceSha256": "2fc364483f5e63ec67545b17582dbc9cb0089415de8a70229672213926f37ca1" + }, + { + "materialId": "2148651b-7b83-4f4d-bce3-7a6c7f1dd049", + "sourceSha256": "5f95f6675305ad3ec46a96e4bb4a45edd5e7678b6cfc2811870f8c8f711f737f" + }, + { + "materialId": "228bf0e0-aaff-495a-8459-98093c2c6fe2", + "sourceSha256": "21ca677f4e7a3693c800efa2eb82757df737856934642bf2e1ab4844aa8d5bc1" + }, + { + "materialId": "238ec956-dd00-4862-ac35-317fb027c8ac", + "sourceSha256": "6b821031130d2bf60435c8c51d38521fd97be572a826544aa949c3d85d8b05e4" + }, + { + "materialId": "23a5bd50-13ca-4738-83a2-f64a38c33c66", + "sourceSha256": "a6cae9606739da41d2e431da40aca68c5e3f809ca658536b38aa5cd3c413afee" + }, + { + "materialId": "23bc7b41-185c-4a3e-b31a-4f92416d9da0", + "sourceSha256": "e9850e25c6a368426311d948b91c5abc8bdb9770a5aaaa2d2e9087efe7370cf2" + }, + { + "materialId": "242a4637-85e2-4e24-938d-4fb9c6d5742d", + "sourceSha256": "72cbb2baf40ea03cde63a9fb2f72e995231b5b638da1bc231530db322de06286" + }, + { + "materialId": "24c2c972-28a1-476a-8e42-5cd19367e7f8", + "sourceSha256": "72f862102e122cbc44f40e5373f7a194fe3a03e6cd36722a60132131f9dae757" + }, + { + "materialId": "252836b1-db1b-4438-8dc1-38e6142af891", + "sourceSha256": "697c288d79caccc6721c0c55ee94eca9c53c8d3093fd50bf45bf4753e30a807d" + }, + { + "materialId": "25707a7c-61da-43a0-9bbc-b7e2637817d9", + "sourceSha256": "c2ae283afc77231646c0aad033609e7e1336c9524756032baf29af34d4fe2558" + }, + { + "materialId": "25770eef-ea99-49ec-ae40-8a8c1a5d0a8e", + "sourceSha256": "6a6f96bd48850b5c7186c49d82f62d11054972877675dfccb292af149cf184cc" + }, + { + "materialId": "26a38adc-e54b-40af-aacf-4b43c4ab46e6", + "sourceSha256": "1f7a68a1740d886f99e9ccfff51bd51b03711fc9b2672163b80b97bbfe9a5c93" + }, + { + "materialId": "27ef5c54-40e9-4df0-a40f-b643d1076d6e", + "sourceSha256": "59050e8463d4316c7897048bd4832aeb58fb9908f233e3b2283453ff0e58d19d" + }, + { + "materialId": "28c298ca-dbe3-43e1-93b4-ddf45e4f18d3", + "sourceSha256": "cbe274cce87cdc39cd973594872c4268c86baba43987885547c30009d49bc634" + }, + { + "materialId": "28cbffad-7920-455d-ad20-251bdf720eab", + "sourceSha256": "5f911cc78df4d1d14410e37ddb30881db71b31a528328ea04b44e9eed70aed8d" + }, + { + "materialId": "28cc30c9-7894-4d4e-b7ef-f5ecf07473ec", + "sourceSha256": "1e5165d5802c248719346de330070db2c739af090404a9f40ea594da95c6fe82" + }, + { + "materialId": "28df909d-8e62-4bb0-82be-4fa25f6ae766", + "sourceSha256": "d03b29593e9e48147d9669d0e5f3b37b72c727f62f91c2ad2d670051b018782f" + }, + { + "materialId": "290da198-c31f-4c06-a176-401882a6ca95", + "sourceSha256": "e531938cadf5d4755d0ee875dfe0cf1f1ffc104d2114f84b20aac9c48610f3d8" + }, + { + "materialId": "29914b5d-ab1f-4880-af8a-eefa024aa65d", + "sourceSha256": "c54f41b4ad240e972958145c50feb0150a695d3c2d00fbcccc56a070e6f00c6e" + }, + { + "materialId": "29c1673c-ce9b-4732-9cc8-5b984d40e0c4", + "sourceSha256": "5dc6cc0db3bfc82156e9cb79054d1c38d54be15bb3dbebff97649eb78497bb35" + }, + { + "materialId": "29e00b10-59cb-428e-996d-cc4378cf1f26", + "sourceSha256": "f3b552db0dc50dda882b655185a0a7bb3c276247f34c07171569cb78a6c0e960" + }, + { + "materialId": "2b62aa90-8935-4443-883e-a4295fe1a72d", + "sourceSha256": "c4f5913b50337a21251fa29b4502fc4ba6cc1ad4f684aaac1d2369ceaeef919e" + }, + { + "materialId": "2d670623-0855-481b-968e-15d1abc89ec8", + "sourceSha256": "6e7147376a7973663d67311a582b691dad8decc5dd95f1fd0c47451055bac211" + }, + { + "materialId": "2e1d7244-2950-40c0-8bae-db9f78f112a5", + "sourceSha256": "8fb8fe6e045f5d68442d56a55e47da6c2795c46d386c39ced1bedc2be50344cc" + }, + { + "materialId": "2f142cb4-b721-47ac-a40d-962994d62f66", + "sourceSha256": "64b2a0c8ff5fb8b61a22848bd7ebf416362dbfe3e9ec8372ac09dc54e5b3210f" + }, + { + "materialId": "2fb4f1fa-9db4-4d0f-bc57-746cbbaf30ef", + "sourceSha256": "9fc135fbbab6d80fb2066564994f14232105e1f4abb9656280dec40affd1547b" + }, + { + "materialId": "2fd1862d-fad4-4be9-9250-768410e09b33", + "sourceSha256": "6bb41c2172b9fde7a4f04f3a3154b80309ce3fb89dd1ce35dc1d51ff46876174" + }, + { + "materialId": "304c894e-e61c-4681-b2a0-39718a24a588", + "sourceSha256": "43f13b02256b3b3260bee6082a0f923ee1908d2c7c62f4355e20a27b84444f7c" + }, + { + "materialId": "30bf464f-fbbf-4aa6-aa9c-47fad22abb57", + "sourceSha256": "fe75551778a14cb14233a54c4ad16787b0d5beb340b4cb7de13111fe3330ab8e" + }, + { + "materialId": "30c56abd-776e-460c-9fa6-9f694e07dc46", + "sourceSha256": "a6ccc4c0ab474210d36e6d2bd328893f670615877247a20616029af15f7d03ef" + }, + { + "materialId": "32692b50-62fd-4733-bfee-04d76478ac0f", + "sourceSha256": "84de4fb03ff6b83e44b52076a44c91b9a781a1b3a7dbf9e186cfe806d5f350eb" + }, + { + "materialId": "329a0680-17e5-4f09-aa41-c545a3adbcec", + "sourceSha256": "b5dc512af7a93a3882091dbf1e74a0e69c3616178000fb83e8554636744ef221" + }, + { + "materialId": "32cb6b7d-29ad-4535-91fe-d6d64bdb1446", + "sourceSha256": "3bd72d6226918ae6ab176307c03cb4b39bd2a7ee570e92ea819b650d3c8c61b2" + }, + { + "materialId": "330ce179-4c39-42aa-89c3-b54d1e29795a", + "sourceSha256": "8ced59873fbe30d7a3613b01f02d431a66ec7a6aa68993af3d6a0d9ed5651c27" + }, + { + "materialId": "33a0c850-3966-4906-a79a-e0b3bfadba32", + "sourceSha256": "1df3f3586fe654b7259e9ff16bb1b343c12603fda62576b0b65d56cfffeb8ef3" + }, + { + "materialId": "33c7d559-6e91-4477-8baa-785a681ee998", + "sourceSha256": "fb46fe4f6f1c0228659faad9373346b851e7c46128ed2a3fbc334517ff520b9d" + }, + { + "materialId": "364e25f3-4e93-452e-888d-b4e045a8474f", + "sourceSha256": "10479bb3fe13d5efdb4b08d07c99e12cd8f4dd40313bd4b90e761e2ac36c8b74" + }, + { + "materialId": "36669ac2-07bd-4444-8d1e-2597f51a1c25", + "sourceSha256": "fc1ce1b747c53389485475663234185a48f1ffc1740bfe2b70f47f4cb079d689" + }, + { + "materialId": "3677de9f-6398-4fe0-9122-36272cc1a0aa", + "sourceSha256": "f6b9d37b7928217c831c3baddf8756cd015325dccaad7c3dd1e84e2f5d35689e" + }, + { + "materialId": "3679779c-4f3b-4d8e-94b1-ab68b1866452", + "sourceSha256": "e9ed087e70234a559d70eb596abbe029ccae2e16a95ff6dbd054dd7d10db37ab" + }, + { + "materialId": "36d2606c-3531-47d7-9612-202b0d60f736", + "sourceSha256": "10209e5f70fb71a417174391aa2d278c64eac3f93b87a8df53152a0213524c9e" + }, + { + "materialId": "37339999-7875-4bb2-ba7c-f76e0acc4693", + "sourceSha256": "3c9c96306ec6eaa3e1352bf13e2696d0d5d6d8d39aeef92aa204cb75a5ff2b3a" + }, + { + "materialId": "3766c668-ec81-4106-be58-83e8c223e869", + "sourceSha256": "17c7f2e71e4547603481845bbcf58a0ad28058bf5f901b08cd9f6991f2c4fc43" + }, + { + "materialId": "37ed9a71-a5f2-4e2b-94d2-33ad2439e77e", + "sourceSha256": "4362086ed075aba733f0aad9a192daced8eab278c83f5846fa15aa5ee09c06ad" + }, + { + "materialId": "3802993e-013b-4327-99c7-75a706afdd88", + "sourceSha256": "112e84d62a77edf73bc9c33208b7bb7e4fdea8e8fe31a43a8f14d6c98d7a656d" + }, + { + "materialId": "3859bacc-7ac5-4a1e-9ff1-76807bd3744c", + "sourceSha256": "de9cc9ba8a00e5be51733a66560f1cd9af949bba7f4fac50cbeaf6f651f40655" + }, + { + "materialId": "3890824b-9b96-4706-9c5f-8370bdf978ce", + "sourceSha256": "12fa8c33573dab7b1a7345295fa285ff415c5c48cbf42ffeb99450fcb7fc3df8" + }, + { + "materialId": "38d5ad15-3aa6-4900-9a2d-47c0d8fb7b58", + "sourceSha256": "29c72beaa3f33f6d8f7426506575387562b1591cf270fb86667325a0c93a209e" + }, + { + "materialId": "3969d9b9-9a2f-451a-b9af-8d85bea4f6c5", + "sourceSha256": "80da27143e8a0ae157d3a4bbfa6ab1453cd7a2c5e7967d04c9836926f2e26cc5" + }, + { + "materialId": "3b16e032-9293-4fac-8838-52ede1f9eb3d", + "sourceSha256": "d706e9eb8f6ba44dbd007b3485fc9aa897cf485e224e957ebe1d4e460a5d95f9" + }, + { + "materialId": "3b44fba7-c9bc-444b-88b3-ac50944c892e", + "sourceSha256": "c7afb116e727143b77665b96550f69bd56cb85bb124a430a2bb4109ae6c1c38e" + }, + { + "materialId": "3bd58720-dd3f-4df4-959e-54dc0f91f6ab", + "sourceSha256": "d3d7a07c1fc61b46cedeed9057ba9967029f7df7974668fb6ca56378777edd4f" + }, + { + "materialId": "3befd3be-62ea-4a30-986b-f1a476b6a47c", + "sourceSha256": "1043ac19e0c5b745de579bcea65d712a70db0cf3d86b069786b14b118b9b187f" + }, + { + "materialId": "3bfcd48b-4d6f-4bb7-bf6a-15f5c1041eab", + "sourceSha256": "f20c1c1e9523b491061e26f7e199c146bb5b4d176d6db986d07cf8262a9312b8" + }, + { + "materialId": "3c807e92-4c44-400a-b434-830ba7d62bad", + "sourceSha256": "c774761fd0155e2c50c318379566fe2af331680dab8b73fb50384eabe453cfc6" + }, + { + "materialId": "3e7b42e7-ed0d-4aa9-a2c1-d2e9a74b177a", + "sourceSha256": "3fb25e89142103a9b610745972ab5c5730b663c2eafa0b7fb90fd6ac669c90ec" + }, + { + "materialId": "3ee1a02a-067f-49b7-a1f9-5c41bba47cf3", + "sourceSha256": "d7da276f366d5827e797811f18e39425b5f9ea8b35fd79cab3df4041095db4ea" + }, + { + "materialId": "3f926f73-bfd1-4607-bbba-6c5ce4008c1b", + "sourceSha256": "9597e080aeb07cb673d39d1e850e967c2cb9c365fb5976a84a6a8334a964460c" + }, + { + "materialId": "3f973438-c44b-45d3-83fc-0335cd507aad", + "sourceSha256": "ea37f6fa93c419824c8657d1c03118656ea06a5e665d254d219144ff56fb8d49" + }, + { + "materialId": "3fb53b6a-f8aa-4f85-a0eb-08a3bf8172da", + "sourceSha256": "6da848ee6582eeb93dba65c4ed50f5380f356c0b415848c3e4c617a01f537c78" + }, + { + "materialId": "400ab699-2e02-4d92-bf93-754d2797d942", + "sourceSha256": "9270062553b3425edb881cae53d376dd708d46b1bc751ce9915d1460d57194ca" + }, + { + "materialId": "40c17a20-8693-4f90-9fcb-54fb20135eb6", + "sourceSha256": "d9066c67118e5ac4d27157856ccac67ed391faf37c116059c10adef240eae19a" + }, + { + "materialId": "40ccdea3-8f9f-4312-a9d7-9deb8f41269d", + "sourceSha256": "bc04c2125ecc9fe382d446346d7d103cfcd50074e220a97c37669a2b162e9a63" + }, + { + "materialId": "41dde83a-0ef5-449d-9ef9-41f9b8a6be75", + "sourceSha256": "40d12440d3ce485369ae41f5d2646b16f5fa4232ca1d02659b42d0711688d93f" + }, + { + "materialId": "43246e4b-5690-4926-b9e6-3b1486a9913b", + "sourceSha256": "196a96609ea1a6946f8fa58abb7ae4bc99a8e7b1246002fce3c85db0ff475222" + }, + { + "materialId": "43a255cd-a8c8-496c-9cd3-961aa2297b77", + "sourceSha256": "4be2332fd530c5f018ae2817a59b3446d355d4857bcf64eaac20da2fe0281a6e" + }, + { + "materialId": "44902780-9c7e-45c2-87d6-db1d919d9dd5", + "sourceSha256": "29c2c9c7280fb51cdbca71b048a35a0b01de1aca8456b159876753e33026c5f0" + }, + { + "materialId": "45068a93-790c-487d-b971-5840291e129a", + "sourceSha256": "0f41eeb378d28dffbcb654d037d701e72a32f031eba55bca3ca9eff29b93f303" + }, + { + "materialId": "4563c91c-7b29-440d-b2f6-c5d93226fdd4", + "sourceSha256": "268e97d25480d0428621ff4f5b3120d029be4fee013248bd76c1b83bc39695f4" + }, + { + "materialId": "459b6973-baec-457f-b944-903eef422c3f", + "sourceSha256": "787ed9321f01833a991473772f6a4f3c938733cd3f1f4483b64020b784b9f930" + }, + { + "materialId": "459ffde0-b710-4e74-b031-09f4f6511f5b", + "sourceSha256": "ad5c3a0140bf997db7bfd78aa20579582a7811a56dfb1e9b5189171b11ce9de1" + }, + { + "materialId": "45fe0ba7-64a4-40ef-a609-4e2707f5c4fa", + "sourceSha256": "9fedee04d9d593c6075c07ff8cdd2d35c7d85d963579fdb2b3efa9c258ed1021" + }, + { + "materialId": "4769e016-ef23-4d63-9d79-1d0f8a82d41e", + "sourceSha256": "7558967d042ea15a272c7c5627b83d1dfbf547328e01567342de3e4a745ad772" + }, + { + "materialId": "4803cb6d-d7e5-4569-b3b2-beaf80f058c2", + "sourceSha256": "5adbe474ad5f9ca167d28ea543cd33ca1dffb4dd57f7daf771203a2315f33e30" + }, + { + "materialId": "4926cd2d-12c9-4d34-b86d-59ac8b430aa0", + "sourceSha256": "90198de987390657d66c22b642870eccbcab0102be1d442dc84f110d672b683d" + }, + { + "materialId": "4a5e1045-8aa3-4f5d-9584-c5c6eed29b87", + "sourceSha256": "27e55ad33d365234a6b8c51103b72b1f159c19b40b715d81d949e67ea9184bad" + }, + { + "materialId": "4a7a2d40-35b7-4b40-a4a0-440cce5d84e7", + "sourceSha256": "d44eb4138699e299105e5117ca00d1b79d431eac1a972a9ac9cdb2c755556337" + }, + { + "materialId": "4c02d503-ef08-42d9-a0b4-45a0135c4278", + "sourceSha256": "92fbe4ab4de916b4bdf221f8cedee0c58499862433bd729a60a606184bba9d33" + }, + { + "materialId": "4c37d106-b8b1-4cf2-aec6-9b8b3841db2c", + "sourceSha256": "082193cc1520424932a21eac9a39829c385043d6a9991389760e10cca88f042b" + }, + { + "materialId": "4c3bd3dd-cf39-44d6-a91f-c4efb0fe07ea", + "sourceSha256": "24494d263a2568b620e9ffa3c7f41f9aa6c0a897900e151b6f6f9f4f7b220b84" + }, + { + "materialId": "4ccbf909-9505-4b82-9e52-9018dc814203", + "sourceSha256": "e60b6b1411d784c1a9d029b2270d37282b2a452ac93ac7fe6a2894566f1ba985" + }, + { + "materialId": "4e1ba0bf-5e3e-4a44-884e-cbeed91f5f2a", + "sourceSha256": "67aa723600c4f5ab06bdc7d7442f148d6068906f7df8c42ed0f196350f16b13f" + }, + { + "materialId": "4e52f53e-8f63-4c0a-b7c1-447cf93d52ef", + "sourceSha256": "cd0c40dd9464a0fd1cf5872ce9069a4cc244d4eb428ab8ded08c1a5fe7793bbe" + }, + { + "materialId": "4e578562-81ce-4a13-b3c6-71fddad63a9b", + "sourceSha256": "d88a069df57b05e4f2a1369371462288233f29e36ecf362fa327e49a41f5c4cf" + }, + { + "materialId": "4ebab3d0-5dcc-45cb-a9d1-eef33743d24e", + "sourceSha256": "dfc4ac2c24f4c9afb2a416709ba86251dbba394c615940a27bb032c5dbfa75ea" + }, + { + "materialId": "4ebeafab-8cc6-48b3-baf3-0db02faad247", + "sourceSha256": "fdd19afe878ea0943333a9e21f2c3251e4c2809d4bb86d56c678467f06e97356" + }, + { + "materialId": "4ee8b549-f8fd-43ea-93d9-99937bf56557", + "sourceSha256": "7ce8700d4c332c0344583e455277654c7dda0b6b14e23d39749bf98e9150b5eb" + }, + { + "materialId": "4ef94b71-3e7e-4256-a204-85179e35ad37", + "sourceSha256": "457909aa8dd8744cc9a8ce2f98544339a032976558c373c1aafaaf450419a768" + }, + { + "materialId": "4f7623c1-3fe7-4987-9e0d-dcf84b1e87bb", + "sourceSha256": "fdc994d2a729886f27ca48773311bc31f6b23a4e59bb9a73aefe46e18d5c5b97" + }, + { + "materialId": "4fd2cca2-9b0b-4482-8121-5eff61c5ff73", + "sourceSha256": "14231d60db9cb85b1a9e15700ec2cd2175df090985763ee2eaf289b31894ee92" + }, + { + "materialId": "50b3818d-b41d-48b2-9dec-f44003d7a3e5", + "sourceSha256": "69f439ba0db23dd97c624c7858cae48f54fa4f5c03be017b9bcdbb7b1147dd6f" + }, + { + "materialId": "50d4dea8-ee0a-4be7-88dc-96fabda223d0", + "sourceSha256": "d1635b149862a5689613a82716f01cdf54b20ad1f303718ea75223df8b4616e6" + }, + { + "materialId": "50fc09e1-0dfa-4edc-9946-d7506848ed37", + "sourceSha256": "c69ee8025b3a867b3c4b5b653cb4ca2ac09ff3a7ba802da24badcc2184a7fffd" + }, + { + "materialId": "51abd8bb-b0d6-4217-ba87-c4a47dab4c54", + "sourceSha256": "20d5219cd89fb1b86484da991c2ba27f1f171275ef4f97e96b64af9845ead340" + }, + { + "materialId": "521ec179-a5e2-426a-b122-0314d9b989ee", + "sourceSha256": "b93393b700cb919d0c489950fb9221858e1839dc179dd9853d2fab119c0f1eb1" + }, + { + "materialId": "529a2db9-7419-4c89-81bd-2a688497b384", + "sourceSha256": "a4d4c2f16c0c2664b4c5dc2db32102bf6a82bef0e9d6a9d1e8a66b749959a6bd" + }, + { + "materialId": "52db234a-7745-440f-9c32-e1d787b1655b", + "sourceSha256": "8687783e288aad2b43680931a98f457260d46582bec893c685a34c2dfc4ff0c6" + }, + { + "materialId": "5356193d-9697-4a25-b8ad-78ae169ba639", + "sourceSha256": "6da7614dd317480404db3df3416388fd4b13482ff6f2f4294afbe41dc7ad5c78" + }, + { + "materialId": "567bc846-2966-4e57-a1b7-0a2202262ad3", + "sourceSha256": "bf5f1bb97fc5eec97e18ceef7083b15c3cb407887d8eef689fa31576c7f16066" + }, + { + "materialId": "56d12a39-ebbc-44f9-86ff-f3fd044b9c28", + "sourceSha256": "17580d73834dfafc3034735325193936c7f11c3f22a3d57ee1679b7bac070f70" + }, + { + "materialId": "56dae9b9-64ce-4e03-b875-7207db7999ea", + "sourceSha256": "97f2c658456169a4029d68dd6a946bd3fbd795d47d985ea9aaf96ae5d2a67240" + }, + { + "materialId": "56f15dcb-1ec7-479f-9030-c52b6cf670b4", + "sourceSha256": "7629d5b8789d6dd64bf55e14e90f138ff4d3b9d73852e35e87fa667a2f4aecb4" + }, + { + "materialId": "56ff6c0d-6919-4692-8cdf-f99f00eddb91", + "sourceSha256": "32bb75ebd35753364a5e1b7808f1edd00886377a038bce3a3868c33ba0e1c43e" + }, + { + "materialId": "57a98629-7ddb-4fb3-8ac0-3b955db30a64", + "sourceSha256": "4f6e2aed1d22a2ac39795693e0c57be5af114cb29360f7474a1798390beaac59" + }, + { + "materialId": "57fbb369-2132-47bb-ab50-f180c4101a38", + "sourceSha256": "d98a36dad8a0d6a91a501dd79504d2dd2b1cc928fd4327e4fd2ac4aba6adea77" + }, + { + "materialId": "58583bad-7c05-4e39-87d3-08705e9bac04", + "sourceSha256": "5e0740a36dfdb852c0bc35e96b5259e7ce5e0d7c87a786689941b6a36c65891d" + }, + { + "materialId": "58bd7a7a-f3fd-48ae-8a51-3bd7ada06696", + "sourceSha256": "110b2586655a9662d479d8af6b084c247135677bb4a2236cd76fd390674cb7ec" + }, + { + "materialId": "5b59e248-6bcb-4d84-846a-d9738f3e9d1f", + "sourceSha256": "4f275649af720ca648e93d4038572b371418d1cc76cc362709692956d4659a81" + }, + { + "materialId": "5bef8b8e-758b-47af-af4f-f94e32d8ccd5", + "sourceSha256": "1c6f64f410c0350c9dbdc0fe64479bb327fd1ec0c3a144df8f885aa13039dc25" + }, + { + "materialId": "5d218384-b99d-4ae8-8d87-8e3f083e5585", + "sourceSha256": "e74dd291b34d499005b5f289922fc49839c90908539dec439c69c45005a47026" + }, + { + "materialId": "5d55bea1-e0eb-4bab-af51-91f827c6a5c5", + "sourceSha256": "e1751595edc8d4e42e60bed1215df97295ceba8ff01e06ac4656aca5d8cd17cd" + }, + { + "materialId": "5eabd518-8463-4b55-81d4-d60add2b66fb", + "sourceSha256": "c1723545b8693685d216eba539e271a630ab7c8e0c41c29ce78715b5896c7d10" + }, + { + "materialId": "5fcdf746-9af3-4944-a6b7-078bf4d11c39", + "sourceSha256": "4c29b5136e4fceed278350af7a78514cc806343175066306832eab5ea7cc82ae" + }, + { + "materialId": "5fdde197-bf09-4831-8294-211ea7bebdb2", + "sourceSha256": "b9114f15862a7a2c1a8b59707e8f58c930c921ccd3a312fa410a51bf11312c09" + }, + { + "materialId": "602acd7f-9496-445a-93f3-2744fbccb40f", + "sourceSha256": "b642cd08b631f53fe333a9c6ec848f318ac4636767d221cfcbe16cb7b16e7c78" + }, + { + "materialId": "61a001e0-2312-4756-a7ea-b0943849acac", + "sourceSha256": "ef7ebb47c03a23d89bc2facfac5afc7f9e7be9cf3a91d580071fa7705a368784" + }, + { + "materialId": "61df8753-f028-4d5c-a547-ff936b217f6d", + "sourceSha256": "2dfd91991c1e6b70e10aca9ebec232f8e224f59814b902043cf9ffb7ff430ea8" + }, + { + "materialId": "61ef3974-7c19-4502-a806-46427251a64e", + "sourceSha256": "bba9f3b758bc01bf75b7e848ceb90b7a30fac83752cffefae15d7a3138fdd8f8" + }, + { + "materialId": "62770a49-0324-4dc5-abd3-232c13a8dee6", + "sourceSha256": "150a2db739869ada5cf7f829fedece7add721fcf02112cbe8d192d576d7acfcc" + }, + { + "materialId": "628d599b-2681-4019-808a-67f15cfe661d", + "sourceSha256": "09fc5c57e0dadc734a0b1d8d73655a6bc50977073808c2fccee742e5da76e626" + }, + { + "materialId": "6379ad65-aeba-4e4e-a407-197b22324a29", + "sourceSha256": "8b1e6118e669624234d8da58d8cf4fb0a00de6835cd4aef19ce9c9797b0f1e67" + }, + { + "materialId": "63a3e558-7aa0-456f-9d72-11c3c55c2fd3", + "sourceSha256": "4259f4e1a005a844e7b0293928ddf25d04e3a887a2684b56231d728eb7cda0dd" + }, + { + "materialId": "63b48f0f-b4bd-4d53-b8ac-104a919fafd8", + "sourceSha256": "3ecfffc2680f75992b9ddd29836d306d4be1f0fa34080759236ad2fd8146de50" + }, + { + "materialId": "6485133c-a383-469a-858d-f00bd5b0b413", + "sourceSha256": "d656fb26775909e1862f10bb6fb722c596659ec154a04462dc7e7dbddb95c5d9" + }, + { + "materialId": "65e4570b-22c8-4483-981a-a42d8930f98c", + "sourceSha256": "1689fe76c76488582df3a29477e349b0f114427f71b282648ebdc8f85c828f89" + }, + { + "materialId": "666ff7e4-84c7-41c4-8818-3c5bb95658fe", + "sourceSha256": "075954b2c141abdef885045a7d0867b56e90f5f1a017d84e10fe17e8abab4dfe" + }, + { + "materialId": "6687b775-cbfc-49c1-935a-05dcf369a828", + "sourceSha256": "9488466a271a90992663cbe4bd05478d9becd5e23b2ba9c6e5001e6c12369601" + }, + { + "materialId": "67362b0f-ff92-474b-be51-a3600b371c08", + "sourceSha256": "5e7c7619a28dcb031911f3ef024e91841330341121df2d292f72a3edc057d351" + }, + { + "materialId": "673e271c-ee97-4f49-9f4b-5c09fd0f849c", + "sourceSha256": "ff5027f4e4881fb4353da989b26d1c618eeb10fcd93d641237d9861203508fc9" + }, + { + "materialId": "6766eca8-2762-4dbb-bb01-255425ec9a90", + "sourceSha256": "35d46fc4d56e9282484e605cb3a174921fe11a4efac46bf5cb29edee0687e4fe" + }, + { + "materialId": "68c0f425-5a07-4102-888a-07f6504ea1fe", + "sourceSha256": "af3763232364f11e83f134cc4e5b2e4c872d393401bab228c98142ffe8bd6717" + }, + { + "materialId": "68cab0f5-e507-4b24-87e2-137fcfb95f05", + "sourceSha256": "a6c32ee4f261602a73cf95ce4dad46a714227f0f098950950decacadc6fa048c" + }, + { + "materialId": "6918e88f-60cc-4acc-8e7e-5e9ba591f538", + "sourceSha256": "2c56d88086e6c1a9798bfd2a8e6d1405f1555897f0a22ba0830299181e7dfc2d" + }, + { + "materialId": "697bdadf-c1dd-468c-a95c-0917321bb264", + "sourceSha256": "ad21dab666d5c095b589bd6018ab1af7ce1198437e47d06ac921ef49de835cc0" + }, + { + "materialId": "69f104a4-b2ae-4ffe-90c3-065973830814", + "sourceSha256": "05462fe36289c3a5a967aabd1d5334704154d4066c33ce5f29c1b96041b2eed1" + }, + { + "materialId": "6a7ba86c-24a0-48c3-9a4e-6aaf883748ce", + "sourceSha256": "cb442cf6df6a47a06cbd9a3e3eeb9b3bffa77b7200746c74539f41dfa1e5b47a" + }, + { + "materialId": "6ab8db14-297d-407e-83b9-a98910f46b94", + "sourceSha256": "1fd0a245ceecbd50e052463343d1f8faa9613a1ce040d2522d6c6daccb8daea5" + }, + { + "materialId": "6b3f916b-3acc-41b8-9080-228edd235da0", + "sourceSha256": "a9ec70e98262e645d407abdf58fb0ea5ce1422ca1c2f23287749245a0733c921" + }, + { + "materialId": "6b813a93-3e5c-4ce2-89c1-54b203876663", + "sourceSha256": "4017c07c35c890e3d29d0be12f7e661c7671039ca8c8255558f12aa068006b19" + }, + { + "materialId": "6c83e4ef-323c-4171-89b1-55ae8280d1a0", + "sourceSha256": "2532c125659f61d19cb30274b66f8263bc9aeedb386ae40765f96a6f9c31c5d1" + }, + { + "materialId": "6d1bc66d-65d5-435e-a97b-7cb8ea3f12db", + "sourceSha256": "781f8683fa1943e660e237aa1179093d5c477307f608a25a07c1a92a2f43cf83" + }, + { + "materialId": "6d8005ee-dd88-4251-a396-391bf339c484", + "sourceSha256": "eb75cada05f0d600fc6114bd6d6cc9b0c4bd8171a7ac94ab3dc5f0e097d43345" + }, + { + "materialId": "6e53bbf1-f891-4dac-84a2-c42e7b99334a", + "sourceSha256": "6dc1b5b9d2f732305e2e83517b0f095f2f1478581419c0b038700c3b58826e5d" + }, + { + "materialId": "6f70004d-4e20-43ef-8cab-8eae42e08ce0", + "sourceSha256": "cd80b66415bd59b8d2b8ff834d54bdec686f384c56f6c2f0d215e6aa839e5c8f" + }, + { + "materialId": "6fc35857-939d-4fde-bcdd-c99978fbb184", + "sourceSha256": "8b8e9ccc61beb85539c4d2094398e3f35505e28b84fbf250ec8d5238b6ae08e3" + }, + { + "materialId": "7060a618-0e6c-4335-ac39-f63b41de2ddb", + "sourceSha256": "1b2ad8c55814c8bed7cb193b124260b8d2848e7f1fc9e130b741b26412a005cb" + }, + { + "materialId": "70adc9e1-5899-49ad-b04e-44869489692b", + "sourceSha256": "7eae974a40ac9eb7757698f4a2b6fb95e5950660002c26b1d01822b5f6edf633" + }, + { + "materialId": "70e885ca-67c9-479c-9c82-49b4171df869", + "sourceSha256": "e30a6ceb9bd5ea1415a3f991b683cc68763cb0428b2b4d60b5154aa4c1661e19" + }, + { + "materialId": "712d213c-d431-4468-bb98-238961ec3249", + "sourceSha256": "abe3090de5317e9e461d7c560f087a6eaaa54fadb386b6a3eeced75e38d73e4a" + }, + { + "materialId": "718734e3-9913-41d5-92ee-0e80b8b7ab07", + "sourceSha256": "fb6784a5c6f61265fcfa41101025e1e323c1673bc32c9400edccd662f852f49d" + }, + { + "materialId": "71a6c0ab-e893-4d29-a578-33a4e36dacb0", + "sourceSha256": "1ea78fff21e0010f2470c0140c31d96717aa4c98ac23e7d5136ed93aa1ee62ae" + }, + { + "materialId": "739e24e5-b8a9-45b1-922c-eff45b887cd7", + "sourceSha256": "28765ff98e2e092752a23fa87301f1f23d84527159de8951f225fd47b19205d0" + }, + { + "materialId": "7472ad22-ba7b-4b47-8240-c36c6f7e59b4", + "sourceSha256": "3fb4b0f90808aef0dafbca20763796744c1a56f772afe3cbd6955afa1e33cea8" + }, + { + "materialId": "747c27a6-8cb7-4daa-be8b-01b13981c9db", + "sourceSha256": "6de7f1d15ac3d0246ad272ceb89cf74782d33dd0730d82247a3be5906e801711" + }, + { + "materialId": "74d931a1-b618-406a-9d2a-d4edaebaa393", + "sourceSha256": "cde79b43d51ae82040c4854293822793831a242fe9d1dced4ee46534701c9fba" + }, + { + "materialId": "751d879d-e324-499a-8572-97f2d26d1284", + "sourceSha256": "e588ceaab1f21e22f7aed93df162011a77ef7950f6719f70bbf3530ebc40ebc1" + }, + { + "materialId": "758ac6ca-1b1c-4b2c-9b8b-3b8bcf0bddef", + "sourceSha256": "c6895356ef2bb04e775039ec93b16a962c8d9ae16e420d83442f339adcf9e700" + }, + { + "materialId": "75cd0c6d-d281-46c7-afd6-2ef1fb9138cb", + "sourceSha256": "4be3d905d0dc689a41f94c4faa941391c23f179970dbe201b5fb7932e398ca5c" + }, + { + "materialId": "75fae719-41cc-4e21-876d-fad94a618879", + "sourceSha256": "6fd3d0e7bd8e40a122d12b2e040c8eaa2785546e1cb60a9d294cf0347a028f52" + }, + { + "materialId": "760dbd01-ee47-40f8-8da5-c452d5c2bc1d", + "sourceSha256": "2ddd397282987e726baac1fcd15587ad6cf46003bb84eb6941ac89142c394a30" + }, + { + "materialId": "77218f12-fd2b-4581-bc50-ef0e1f1378f4", + "sourceSha256": "023f01fc1f9bf64eca82ca3916109986135b7ab57f6abf30720c39a09057834a" + }, + { + "materialId": "78e00620-6acf-4eb2-a4d9-db8faa06c4f1", + "sourceSha256": "82efa49afced00fa1cdd5919ee671b49a3653c98826a9f8ff8b39e1320b6e2d0" + }, + { + "materialId": "78ff1c03-c0fc-4482-9238-8af648375920", + "sourceSha256": "80b37833610d6e48e7c4b3a6b65ca6b0d29f7c9f9b9aa6dbdc2ab1579c7c17c0" + }, + { + "materialId": "79590769-9cc9-4e16-9aa2-8c0bdd34f431", + "sourceSha256": "a4ef319d20b5170f86ea4ffc93d148fe7c0fff32dbe81fc3f833dd400272c6e1" + }, + { + "materialId": "7b340ec0-9019-4857-a078-1006d9ebb25d", + "sourceSha256": "573417eee2810ff7d3918fc51bf555875002e54c354b9d1c1d20d4d4cf57443a" + }, + { + "materialId": "7b5f26dd-fd96-44c7-902d-524574c07fa1", + "sourceSha256": "77e4c617f4916e4562826af8fda7571c3ccdb896a17aa9c29cd76bb475e3711d" + }, + { + "materialId": "7bf01375-607c-489c-9d3c-dcaa654f1806", + "sourceSha256": "f95660c79df6fe2435c41cc4b1081b16e0b1a074119c35de952aade1182d3298" + }, + { + "materialId": "7cc7ce66-d982-439c-9cc6-b88e450e7416", + "sourceSha256": "6dbc20316f1a1b668043dc7857ab247aef2483707afcbbbe8030aac480f40fed" + }, + { + "materialId": "7da90206-dc0d-4e7e-b4a4-68bb088d3ba7", + "sourceSha256": "90f685960ad48bb68de41eb53d05d66622b8217de511ec95dbd42c6082965b1a" + }, + { + "materialId": "7f10f7ae-b684-4cd7-9160-a70263c15b55", + "sourceSha256": "1c824f801cffaeb6e65be7979ca51bc5f1bd7e2cea0dec27e47985999357a964" + }, + { + "materialId": "7fe4c7ef-0961-428e-a70e-d8ca75f60e7d", + "sourceSha256": "ac2c22df3d691381ff8e3bda61fd097ec26420172879a212d69d35214e4f4f14" + }, + { + "materialId": "7ffdf0d8-8794-4ab5-9963-b46362296734", + "sourceSha256": "0115940b760325fe74be0e009b5d8d314f34ada1ff40c278ced2b1190ea53412" + }, + { + "materialId": "807628c0-8298-4828-bfc8-e6695bbec125", + "sourceSha256": "fc7e36a152af381eade82e11d4bedd03b782c1b011834d12df5c9cf1c91c0ba4" + }, + { + "materialId": "81251f5f-f29a-4b61-a8fe-7fdb8ecfcb06", + "sourceSha256": "9f6cbc7457118de5b95880f2a955809e5943758adfd2df671a99f76e3dac9acd" + }, + { + "materialId": "81df7c04-b1be-4e12-804b-f0a72a6a07c8", + "sourceSha256": "af42710175b07c9f7b12c089b2d20f27722a59221d77878147f65bb77c1302d1" + }, + { + "materialId": "8386499c-b099-49f3-a781-9b0b20d19ec1", + "sourceSha256": "b97dbd4848c5b8125b76cd0be2ecbca655add0df3c907c581a331f10c030a79b" + }, + { + "materialId": "8394125e-eabb-40ae-ab73-9c84bab5ba31", + "sourceSha256": "46930382033817ae80aa1833cd8265479e52d370a94b0119729fa49e5726662b" + }, + { + "materialId": "83d6e11b-5f13-4896-8129-d2dc0afbf64b", + "sourceSha256": "d9daf7ab6af85cfa963bf5df394f13dcc955a106d1102606a622a513beff4754" + }, + { + "materialId": "84cc81dc-40fb-4c9a-b618-115f2135f46b", + "sourceSha256": "e5ac91bf9e830ca5a3e3eb6269953a41fa6c51882c7256917e774c40b40c6773" + }, + { + "materialId": "84e7e2c3-f5ef-4db4-b7d6-1ca32183c27d", + "sourceSha256": "8b31555e7ba965f38662c1be993cd5948669de37f699372ca7a25b4028421466" + }, + { + "materialId": "84edbe3b-61ab-4928-a424-036d9eeab605", + "sourceSha256": "883e82464bbf7353f40c61a55baa9c745a611b301bb91d676c245570a095bc87" + }, + { + "materialId": "85632bb3-f825-4674-ac9c-f599968fb8f2", + "sourceSha256": "35dd514927b225c484f0226ddd1e74bbb57faecaaf36f7870e9b81b2892282fb" + }, + { + "materialId": "859c7395-51e5-4a4e-a359-96635ac3ac3d", + "sourceSha256": "84de9eeeb04b7c8065c7550c5efb50939f726c38cc1e8f0e609a9e50f3fc0261" + }, + { + "materialId": "861b2fbc-fe89-4191-8647-915aee148e63", + "sourceSha256": "10473482d941129f2f4753a1fcc169dd240329a723b8ccbe5a5f27b38cbfeb63" + }, + { + "materialId": "87343317-a050-4d66-bdcb-23a405fe805e", + "sourceSha256": "595b04a52b81cfcdb4c809c16a7bd289bc78692b42bc1a9521aefcdd26a3f1f6" + }, + { + "materialId": "88406fc6-0d26-4900-a659-df46d6285993", + "sourceSha256": "f244ec83a96cb4a0b63705fb42ec301cce4fdad46bad5e1b6f0083ea6b847b25" + }, + { + "materialId": "89ae088e-a51c-467f-9e02-1093310926fc", + "sourceSha256": "c0eb3744d8d14e89e2357b9432d01c67bda5dd9202d037df52426d3225d967e6" + }, + { + "materialId": "89c59b80-45e4-4903-97b6-417396f09a21", + "sourceSha256": "01c20717ce41dc763546dd77fda2836e1dabdf1938700b211f821e3c7afa47b6" + }, + { + "materialId": "89f9cdb5-2316-401d-becd-e8b6ca42b08b", + "sourceSha256": "8ed370c38d52a147f2e0dd72a44b5498a557c82f00ed5806510846ab1c12512c" + }, + { + "materialId": "8aba09cf-0f8d-425c-93d8-4ea70039803a", + "sourceSha256": "237b6d80cdae2f29c4e3484372ead044615ba06a8aa56bc42c2296169b3a26b5" + }, + { + "materialId": "8acc986e-e342-4119-aa73-b068e4394b3e", + "sourceSha256": "b85468dd4258bc4a1d780e8c67d4ac2cab37cf44f3609b42446f52fbfb5dcf1b" + }, + { + "materialId": "8b10b381-3f2a-45ea-93bd-52cb04030de8", + "sourceSha256": "f54ad12e16f2d91c48c55a37909f22ebbbf9e4fd030f235377fb1c7fc71d12d1" + }, + { + "materialId": "8bdfbbc0-6158-416f-9af7-37b8ffe5c56d", + "sourceSha256": "3eaa281dc4cebda0db9c070dacdda2518d14a222e0623c109f5aab93b9ffcc08" + }, + { + "materialId": "8ceb3eaf-b3ee-477e-ab7f-f81ffce9bbe8", + "sourceSha256": "82b65024d2a14189b5cc2097ebb889386742b018affba4cea57c2e6a42602964" + }, + { + "materialId": "8d522931-f969-478f-89d6-e75be103020c", + "sourceSha256": "479439c82a6ead98d08a8ca05e95d3c3d0e57388d642b46952f7bee25734905e" + }, + { + "materialId": "9083eaa7-2b71-4508-9cdd-6892b8ca3993", + "sourceSha256": "0d7ee09437f98f51c40a499bb1a18023fb4cffa6d7340a1285498608b92c4c16" + }, + { + "materialId": "90b92b8d-980d-4132-b26b-491983da2acb", + "sourceSha256": "3b90fe9a4256f9072da7892b78ad5f72ff96435ff782365974f65b5a95043ccf" + }, + { + "materialId": "91ba8833-c608-42ac-bb27-23932547e28f", + "sourceSha256": "ceb342fb6a9676b7e44cbd89ad8f01e4ef6e96c7f39a71c7fd341971c16b36a1" + }, + { + "materialId": "92478538-acec-4b41-8d5e-f74af954b8d0", + "sourceSha256": "307c0d2153e3a746c656c886682aec72def823b413d4e55fbfb782659455b1f7" + }, + { + "materialId": "92a25378-d4b7-4bec-a0d4-b501e9013a53", + "sourceSha256": "a5d47e8c46568b06f0afb3fdfcd74b027c0b6c96f266b500e4c693fdf8bed0ee" + }, + { + "materialId": "92f082e2-8e98-4b4a-b898-bb29a940ac30", + "sourceSha256": "938abbba45b25ea00e2c143ae561f9fed4a6fdd3ba4b68e72a0b032e1acba9d3" + }, + { + "materialId": "93030510-2a11-4569-b62f-7d817943ac56", + "sourceSha256": "0fa982cc5edb9b711a75e6df4589950e7569567e401e2b1f2e1122a06bf93804" + }, + { + "materialId": "93159468-5379-44a9-aeb3-cc82000a9c30", + "sourceSha256": "ff12fa27ebd066327732e1b5fe8e5f61a217456c1631bc44ab8c6fe01c15415a" + }, + { + "materialId": "933e7363-81d7-4315-9ee2-4c15b6b1dc3e", + "sourceSha256": "dbdd8c2b53a5de1575799d1665646398f8914da2597f6b92a84a064227fddb40" + }, + { + "materialId": "9516e94f-d3cf-45ba-9d3d-28ebcae826e8", + "sourceSha256": "71acb0d692fa3493c1565181a2180bcbab3c836d3ee7251125b1475523a51d3c" + }, + { + "materialId": "95b9e57e-e7b2-4b0b-8d10-a825aead7292", + "sourceSha256": "3049fa479f1c5904a40e6f390e30c21ac8188b9428e6552b720b5bd09b7fa3bd" + }, + { + "materialId": "95cefbaa-4e8d-4f0e-81ed-68dfb897c6f4", + "sourceSha256": "bfc58c2c16e45c77adfe50c92aaabc64b5a3494b9eccfcc5272dae39f49fdae8" + }, + { + "materialId": "95d10099-cc8e-4a7f-8099-98d57148f434", + "sourceSha256": "9c996e3a199bb2fe164b00c659d098f887b4300b37f46f5e4f245d5f2825f4dd" + }, + { + "materialId": "9699a0b9-234f-436e-9c60-2b6f793f1b6a", + "sourceSha256": "5aeaff5fb413bc2745da3115e6d0bd1a79523d48c03b5766f7ee54c9159a4907" + }, + { + "materialId": "97135a1b-3d1e-4379-82a9-ca64d756f293", + "sourceSha256": "264bd6afd97b8b407b758caa8ed99cedd333c9349dcee407d417ef1d473c13b1" + }, + { + "materialId": "9740ed75-0a5d-4a73-b07d-250561991e1d", + "sourceSha256": "1c6fa28e2ecfe6e5e1e29cc8e8f2fa674f2b2e92ea0f77ead3d9cbb3ba568659" + }, + { + "materialId": "97b58f34-d869-475e-83a5-e32b634d7c61", + "sourceSha256": "8859a4a9121f38f9a5b8d8bb51fd7a0278cc287a2973293d3aa6693221a9f4fa" + }, + { + "materialId": "99644ca0-6b7a-476a-bf14-4c8affb08f7a", + "sourceSha256": "e4f48e405311a414809b0ed268d9d2578d5ed93f3b20d0db5024ff872bd98873" + }, + { + "materialId": "99b4c2de-f647-432e-a210-be72796bdbf1", + "sourceSha256": "93c9d6573defb2787056f2a2bfbfc3e30d9845c910dce540fa2d361504813fb9" + }, + { + "materialId": "99c9e49f-6530-4af4-ab57-bc5dbfa91c24", + "sourceSha256": "feeedecfc293f5605484e1133d9b26e5220a343fbe880b187fd3089bf81f9851" + }, + { + "materialId": "99f38926-62b5-4f21-a0de-2dad5442f24d", + "sourceSha256": "31580a87ebe39e22e5a708792fab69ce8ea563e5b865b50ed893f113b3c9c589" + }, + { + "materialId": "9ac253dc-04b3-4fbf-ac43-ec04f32690ea", + "sourceSha256": "5733f76537aa7cce4de13c0af3e8d83e0f6cf20847fce03798d38546f5b692f1" + }, + { + "materialId": "9b1ffdad-b7f5-4e72-8537-348548c6e0b6", + "sourceSha256": "7bf4940e7cce5957535893e03c401e9801249ae20ee994cac34fbbcc23ac8d3c" + }, + { + "materialId": "9b6c2932-adcc-4c92-a2bf-52b606b0e12c", + "sourceSha256": "0dce16ec7ac10fc2e5a90d3928143aae35faa1bb2dc5df21fcd5a4db3393d1aa" + }, + { + "materialId": "9ca17c60-3d3d-4b5d-95f1-433d4a786bcb", + "sourceSha256": "11a56aa64d82d8816ec71f62a7a1ec14e2396d571cec1adbe12bc5987b8898f2" + }, + { + "materialId": "9d05ccb1-3eae-43a3-b9bc-79cdefba9c81", + "sourceSha256": "da456019b140a375c9140742e9993a09b6995238df4ffe47a5cbadece9166cff" + }, + { + "materialId": "9d51ff9d-8664-44b2-ac3d-709ec31c4828", + "sourceSha256": "b1ed35a431510296d8cc95c204826049fd1e2fea598c9ef184794d0f1d85f7ba" + }, + { + "materialId": "9dec8235-9861-4b15-bfef-d94fd073b4fa", + "sourceSha256": "b9f0dea5010823ccca97f0ecd809fe580e6f88cf09871c913e63592763b2d2e9" + }, + { + "materialId": "9e0bfbb4-05bc-44db-bf4f-009d734da34c", + "sourceSha256": "48d9b9ee938a6a15d76db497239b8adec8dbd140102e9cca1a9e6815da526519" + }, + { + "materialId": "9e3433a5-7e51-4f64-bb1e-f9c2f8e0a780", + "sourceSha256": "b715fe7fdd995d49d4d99900cafa6d265780349d3292dfcb7d48c6f721799e70" + }, + { + "materialId": "9eef0066-c335-4fa0-8011-6440519f9735", + "sourceSha256": "e840e31c6d036aaf891920fd43231a7d77a26dd0c28beb0f51c800fcb6fc24ef" + }, + { + "materialId": "9f3e55ec-b94e-4373-97fc-40017bc5262e", + "sourceSha256": "fd169e014b63299aed7ad4150adf812f8cf016af115180f76990dbe3b02c6040" + }, + { + "materialId": "9f59dccd-8127-4d91-a807-4802233d54e7", + "sourceSha256": "5e5bc3fac432573821926079df33a28145e443724c3ee3ce569013a272279427" + }, + { + "materialId": "9fdaa233-dc0a-4acc-85b4-7567771e42a5", + "sourceSha256": "e5358eecc14269e1875fd52ac99b5743de1372049e17c78107a4bb38c62786fe" + }, + { + "materialId": "9ff17cd8-eb0b-4b96-b269-982d7bced03c", + "sourceSha256": "dea1299542f1f5a398b4f0b85d7b81b1334043f9a07ac64a51f68c55bc1e3c85" + }, + { + "materialId": "a006e22a-5dd1-4e07-830f-8eefdc8ced12", + "sourceSha256": "3071d34d2017cd5219b268b5e69b7e0ad25b386027c5dff0a87513f67d7631be" + }, + { + "materialId": "a15b7c93-bd62-4497-a9b5-7f277ffdc764", + "sourceSha256": "9f648b21fbd26e5424f72fdaeef9555729deb8680d4f4aa30d96b143d5bd0452" + }, + { + "materialId": "a167a101-bcab-4166-95c3-c68f2479c7bd", + "sourceSha256": "ce0fa8c5b97125a7ff08d797f6321440373d7bf0619f97b820ee12d789329010" + }, + { + "materialId": "a202f607-9413-4895-8c92-ac593a3adac0", + "sourceSha256": "6039b7cbfe90ce1cdf57eeabe65af3fd9cc0e979c30a7df4c9f545baf56b7de8" + }, + { + "materialId": "a252eb77-cff0-4810-b238-7bad4a62acf5", + "sourceSha256": "4f584eb72428565ff63fc9cff4fd4d8e9fcd9fa74904dca9342d7bd09d524d73" + }, + { + "materialId": "a2e80feb-bf5f-4390-bc96-168d8cc16e37", + "sourceSha256": "610a9d601674cdca1ee910c3e4cdca8e3c225aa2b34ca976b0a3cb8d9d57a8e4" + }, + { + "materialId": "a38592d1-606e-47f1-ac21-7218ce9fb1c6", + "sourceSha256": "c0d787031a4608d9b77a7aaa853ecd4eabf1dbd57fa8e72afaea6d782b41566f" + }, + { + "materialId": "a43c0ebd-a00f-4170-a0c3-4e261d22781c", + "sourceSha256": "9c7675ced745c26be4011491132186ff2914a9c91142096a893027d58acb5abe" + }, + { + "materialId": "a452f2f9-50c0-412a-b22f-96687da20b96", + "sourceSha256": "6e693b269191aa8de686150a28be0459f8e586dc698abf18f5e607b08215abad" + }, + { + "materialId": "a478d3bf-526c-48bb-a8d2-730a94586de2", + "sourceSha256": "7cbb73fb666ab200a71692463c9ab771bede137029b3eb80d459cc5c971a912c" + }, + { + "materialId": "a48f3276-98ca-46b1-9083-c21fb8031e69", + "sourceSha256": "a1df6602cc5efdd16ae3529e8384b3126c763c9da8242a05d370e1949cf48e10" + }, + { + "materialId": "a4d6e15f-e878-4943-b936-931dc3be43c1", + "sourceSha256": "0e9d38f588199ab7a4c9eed201fcfe78401c69ca137a4eb2c6d0fe3ec8cfd9d5" + }, + { + "materialId": "a5b8c082-1d40-4038-b349-ad2e58dc16b9", + "sourceSha256": "ee8b447020f3218c4b43827454bb96746e293fd6b91080a174405738d7e15471" + }, + { + "materialId": "a5d04c94-e8a1-439d-950a-352807a5018e", + "sourceSha256": "cbc29c5c08aac1be5317d074e3d52caef4c6bdd36a166b76c0193d293dc695a3" + }, + { + "materialId": "a667990f-a433-40c9-90ac-605a195a3ace", + "sourceSha256": "413845c7ba836dde5b42bb9d688fdfba1a50bb23fe8c7f9a2caf81cde785bf94" + }, + { + "materialId": "a7bbae39-3fcc-4927-b640-3e3078db79b7", + "sourceSha256": "e28f54edaf63b02bc4186911fabbf2944983552a3690755112262f145e7984f7" + }, + { + "materialId": "a7d4be90-551b-4bbb-bafd-186c9e34bca9", + "sourceSha256": "aa5471422e24ef713f4499988f9da6ef1bf2e272aa73b9c84246310d505747f8" + }, + { + "materialId": "a81b9c98-065a-4a65-8aa8-bd46f0baba1a", + "sourceSha256": "0ccf0cd8023fd30abd7e9f997cc1149b71c5117548e738eecafb6742d374274f" + }, + { + "materialId": "a8531cbe-d295-4cc7-bde9-8ba49780bd88", + "sourceSha256": "d248c4c59bef9984169fc8e5be4470cde7f70e9f18c0aa3a4862b4611b1bca86" + }, + { + "materialId": "a8546e2c-4d5f-4f7f-99e8-e3ddbb71794a", + "sourceSha256": "63bf2ae02dc76ef1fedfb1210ab327cc8c2677e9d3786fac83485cf2862d10ee" + }, + { + "materialId": "a8d5067a-1ec9-4d5b-bafd-aec832e1fba8", + "sourceSha256": "1ab3006b49c972d20b09aae55a05fc98938ca15b6c7408d940f9586465159e66" + }, + { + "materialId": "a8f54f9d-6b0f-4453-b38e-9af57eeeafe8", + "sourceSha256": "2466a421f57c7ed6987ca4c7c3937fd811b759ef91e5b521cf3240a41613f579" + }, + { + "materialId": "a9618894-8f53-4f1b-9209-aeda65344b2b", + "sourceSha256": "2bf4dcc9ca3449297a63e5c25d580e59698332ede69eb69a865c89cd507e2a07" + }, + { + "materialId": "a9761a49-91c5-4f32-98ca-b7427b513e1c", + "sourceSha256": "18e48041a5c017e11e1c8281c5ebb8de09b9a305df61a81c53e86c86ed20c252" + }, + { + "materialId": "a9d35a46-2bd6-4d6e-ad53-c09eca618218", + "sourceSha256": "9dd638f94c49e4ccb81633e969f9ba923191f1c1f341024b62e599422eec6032" + }, + { + "materialId": "a9ecce5a-5e3b-40a9-aa10-ce0dd3d9f24a", + "sourceSha256": "ae6853fa99aaf7a7906351e5d13a1e8ef3fc22de0d6cacf5d7aa74bd6019cec2" + }, + { + "materialId": "ab1fa2b8-3337-4443-a0e5-f2a9b40fdd22", + "sourceSha256": "23fddafa5cf6d6b9f93478bd1109ccecaf7d4ed109b351e75901fcfa8180860a" + }, + { + "materialId": "ab5e7684-0399-43fe-bebd-c1be2eaa46a0", + "sourceSha256": "9024b0e3024292de55e2a7fafa24bbfc6d9a842eaedb3bfb1ddfe76fbb63253f" + }, + { + "materialId": "abd55006-6d00-4a58-89a6-883343378b68", + "sourceSha256": "488d02813bca8495cd1558c7fff78def0723fee38a909bba6ac9e44b5039bf00" + }, + { + "materialId": "abeae0bd-e854-468c-a124-2693a38f9525", + "sourceSha256": "be68b8045a54cc1d3ce39c66c8729c4935e13e28a47903601bde883efbedbe7d" + }, + { + "materialId": "ac14a475-3b04-49b8-905e-f5d5d7535757", + "sourceSha256": "c6274af3c6699476d1a6555ed42b97ce6d325dcffda64335525128049fc820db" + }, + { + "materialId": "ac1ea5a2-79e9-47dd-a02c-9386e8f59a1f", + "sourceSha256": "05981b0c975b7a36097ceb97b5bbaecf3c9af519ed3ef101388ad39ee8312715" + }, + { + "materialId": "ac478b88-5445-41cf-abc5-50a82a35880c", + "sourceSha256": "61a29ecbee195988d3b161766a830f580e0c16fe68fe2bbfaebe1f860d8c2a3c" + }, + { + "materialId": "ac633252-3f4e-41ea-87c1-d1164547afd4", + "sourceSha256": "d3bce62e0af435a7d793d7a610fe29dfdd99a23d66a5b2961a71aa90ea532f1a" + }, + { + "materialId": "ad27545e-da14-482e-9a50-834ccc750f36", + "sourceSha256": "93cffc3a3e2a5c426888dff16935d6159fceddd3ecc9ce28613c791656739cfa" + }, + { + "materialId": "ad6dc33e-8a03-45e3-b694-9f52f2ce667c", + "sourceSha256": "413d5d83380428e6b12892b127e4150ec22d999e14a1d5bf2a65b1d1f66415ca" + }, + { + "materialId": "ad92b653-bfba-4a3a-848b-7c6c559430a6", + "sourceSha256": "61f484fd8e56844e0e1e17e5f63b05bf5b1e804705b45e18d4ad0849f4dc2cfa" + }, + { + "materialId": "ae0d732e-db43-4b38-972d-f14197e3bcfb", + "sourceSha256": "9baee788faf23b8943bad62d089cba6ac65924c188fb2b57fee2f81811de90e1" + }, + { + "materialId": "aec53536-2bef-494b-89cd-c747e5c4c665", + "sourceSha256": "1e50474787193b1cb79dd75843a9b73d21557885e95206cd321dab3e0378316d" + }, + { + "materialId": "af150e5c-3474-4f90-ab5d-0a3ce585c20c", + "sourceSha256": "da60e65950be4df9d88c20215673c7aabaeda9e34237706ea7ffd9997807e11e" + }, + { + "materialId": "af8289be-5528-4514-bc47-7b3b2c9625ba", + "sourceSha256": "7ce7c65031f5a1dc4ab32db5e59cb7aeec4b87e1143380f38ad574759f2c6955" + }, + { + "materialId": "afed5985-130c-4569-bbc4-de9ac6aeaced", + "sourceSha256": "07e3b956a09874fd64c6da81e312af85ee6fba941b1b7e56340d08e7f5a506b9" + }, + { + "materialId": "b06d08f9-db56-40cc-98a7-1863eadad05c", + "sourceSha256": "a6bc8c87cd731acd86aebf90efefb1742346390694d88dc36d0b27307b2c073a" + }, + { + "materialId": "b118932e-2b30-4701-9c9a-7980645ac973", + "sourceSha256": "719e51e696e5a1978a9246239dfef4e079312c8a88c7e2c117f460885111a710" + }, + { + "materialId": "b1975f9e-9d1a-470b-9fc2-c08af960ef15", + "sourceSha256": "52f062ab8e140516391ce5fe50334645ac8e92290f24541d49585a5ed7580784" + }, + { + "materialId": "b2332cb8-5690-48bd-a130-c9f0e5447a43", + "sourceSha256": "0d16eac34ad38f7483bd99ba9b5c0e30b2637eca09b958000cccaca1f5ea2065" + }, + { + "materialId": "b2e18993-1b23-4fa7-b4ce-f962976849f1", + "sourceSha256": "c408eb0957c928dbdb1daf1c4be2bbb7813b72d9e43dff0a18702d6f3c916e65" + }, + { + "materialId": "b2f18b3c-fa50-4021-ac6d-e6a83f71a164", + "sourceSha256": "e01eabd5152c2c47e999951955d343da9fd15b60809f0833458dc344d03b8bef" + }, + { + "materialId": "b4abced2-212f-45a4-a2ba-fea8c2db1d57", + "sourceSha256": "0b3a2cd8daa8b0b47868d4fe007908d511c56b10dc431f22cc068f5d14e7cd67" + }, + { + "materialId": "b512f288-251c-4b36-bb3f-ba6808d84669", + "sourceSha256": "13ebb3591f43e3bf0f6db45f5ea14d28edd48adfad831cdf3baee6dc63cd9ac9" + }, + { + "materialId": "b532a3f4-8c6e-494f-95d2-81a106de480d", + "sourceSha256": "94e47cbb00e7e8043d422b1f66224b84a1a00d2df948608b90b0623f66038cf2" + }, + { + "materialId": "b571ffca-7918-42e7-9077-47bb39059f5f", + "sourceSha256": "2c443ca6c30124eec468526a23a4fcb0a0db4f81e62a68c85fc96fbb00faf434" + }, + { + "materialId": "b6093774-0928-4046-b344-4e40c82429fd", + "sourceSha256": "2816a0ae6b71f1f13b9f9a2804b6225c0d6746c4d3fe6cf3eca768de901fad39" + }, + { + "materialId": "b6333048-4c2d-48a7-b0c0-a67a875005b0", + "sourceSha256": "d2a13c03da62624fd784db8e0fdedc420578d07f19d4b4d9fa319330b40b47a1" + }, + { + "materialId": "b690912e-b881-4f08-ba31-52b69f8af504", + "sourceSha256": "0b7c652867c0e4933d544d3095ae0fad7ee57c8b32abe9a6560574f1ae07d3bb" + }, + { + "materialId": "b6cac4b1-4f9d-4e57-ae8b-a2671cc6328c", + "sourceSha256": "6da518232530c1b5337f6ffc4e32fa4adc72024a058aee8a6bdaa8dc489e4fd4" + }, + { + "materialId": "b7cb8a85-9637-4474-acdf-3b45a0a360de", + "sourceSha256": "b4c4af08d9d29b339e059297724c00f80174589cee9093cec682e43d75d5053e" + }, + { + "materialId": "b846469e-909a-4d42-8fb9-030d2abe9245", + "sourceSha256": "9c010eb7a344432c15db0c1de52efd1bde40e6b969561e3f18e78196f21c205d" + }, + { + "materialId": "b8b822d7-fdec-497c-84e7-8fcd4e44b1ce", + "sourceSha256": "c71c4fbcf5182f87ed8291e8fd349415a685042a1a758777f76a23691d113874" + }, + { + "materialId": "b93c47a2-a58d-49e5-ab94-9cad13772725", + "sourceSha256": "77c39044b8fe0943cf4bf9d50998591d8489201685842af933c0868c15ab99a4" + }, + { + "materialId": "b9cacd2e-bea7-42f6-889c-e34fa61af16d", + "sourceSha256": "3906e9989521e1eaa913df1b366e24ecca3668f79d69b5193da79385c722d9c0" + }, + { + "materialId": "b9ffc7a5-00f2-4c5a-96ea-d384788f8de3", + "sourceSha256": "5a34e694ecf0b312d3202c67a91796206c76f33c04c7c244aecdf6d6030fe8a2" + }, + { + "materialId": "ba172796-f6de-4649-99a3-c110a2cd66f2", + "sourceSha256": "4654168fb1ca458945f964f5ecdb19ebff4446235afa70e07177234d0209c3bf" + }, + { + "materialId": "bae84068-df32-45ca-9919-e15180c7c6c7", + "sourceSha256": "aff2a25c326f78907d1a12079e464ad6cc54e502dcafab948332d6b176fffb81" + }, + { + "materialId": "baf4ff0f-7c55-412b-96ca-f6023b7e9593", + "sourceSha256": "bc042f45192f8f7056c6ce7f322c8236d2b4fe687014ddd8a8778fa298707570" + }, + { + "materialId": "bb7435ce-5208-41be-8fc0-4f48bc058ad2", + "sourceSha256": "9a543666dbfd1576680ba3277ae32f75ed8966bfae4608d558ffc8b717a98f9c" + }, + { + "materialId": "bb8e983f-74cc-4b06-9223-daec1d4cf1d1", + "sourceSha256": "0222d8da8cfdf71977bc48387cbe092038f2fa0cedb8433d378c25abe6bf836f" + }, + { + "materialId": "bc12f966-ffe8-468e-b0b9-d1a7fa8e4462", + "sourceSha256": "ec5ca7eade3243da0fdcf7d1f957f6ffe155504b66e1446fd56097a2bf522560" + }, + { + "materialId": "bc730452-5944-49a9-b343-144f204a9991", + "sourceSha256": "1964975d6bf5e999cc669f551b21a5a8d539c7eb36bd1f1a090ec644af0d8e34" + }, + { + "materialId": "bd74c8d0-c580-4b12-9599-ddf7fe20e405", + "sourceSha256": "7ae0f77e86ea35efecf92d0c05aa662bea4c55de6eb7f2f65020b54c748cb896" + }, + { + "materialId": "bdd69f5c-6db7-42ab-aef5-c789a3bf0247", + "sourceSha256": "65c68cb92389f3c4ea00ec197e6546f7086a8b4e522826674121ab22e44f5d39" + }, + { + "materialId": "be316fb8-331f-45a2-a726-c625f6bfede3", + "sourceSha256": "50120db67aba63ff3eb8293455a521a776eae662c1ec5785a0b061a9febb4f90" + }, + { + "materialId": "be68b890-7cce-42d4-bfcf-21d547d35a7e", + "sourceSha256": "22feecfe7e1226e200738e535469226ca5123c7890cdb6b0efd3f7a207791737" + }, + { + "materialId": "bf48f09e-55b9-423e-8d28-111f3378e2bc", + "sourceSha256": "31a3720acca1dc1c8468ccff8162347b1fa8ba262901f457a24abc744bd01559" + }, + { + "materialId": "c1335873-216e-4acb-b113-8a008e674641", + "sourceSha256": "c71de366f232edf876a1c1220fbddab0333d701a0089a0771db1cf9108232a3a" + }, + { + "materialId": "c1ba0475-bf60-402b-abdb-3a14266d8590", + "sourceSha256": "03ddbbf5fb28a579054832483cb2dc76f2a0f99d8c6e2c41dbf99a9c56ca6f24" + }, + { + "materialId": "c37cb3ef-e85b-47b8-bf4f-5549bbc7cb49", + "sourceSha256": "8a0f149a9fe97dde56900b0a01f037a14af4ec764974aec7d421ffbe936c8c06" + }, + { + "materialId": "c3e20bae-d67f-4528-878f-d5ae3bba099d", + "sourceSha256": "3e5b0f55aaf18da6a8e2495bdac51e185721ed6d6bd65ef8d650293492ecfa3e" + }, + { + "materialId": "c4a82b22-b8fc-498c-9b70-5d17163a8fd6", + "sourceSha256": "9b12a203127053d488fb3a560ee2c26cfdcdb25c969e489f6943d242b64eec2d" + }, + { + "materialId": "c4d3e74e-83da-476d-9544-ef565a3b0a83", + "sourceSha256": "1b441292136d09b992f697d5271e94ce683310684d46ceb51bd3b6353e2d5ee0" + }, + { + "materialId": "c50b77ff-e2e6-400c-8bd3-dd1c9076be47", + "sourceSha256": "602d4530b0935f9debb9fb8d21193092ee2247e297652bdc4ebc0c0e64bdfab0" + }, + { + "materialId": "c5dbdff5-d919-4e3d-afed-53442ac46e54", + "sourceSha256": "559bbca50dceafa1b3aa37cba4960e34818a2543a8c24ea7162beed4a4e3b441" + }, + { + "materialId": "c71aed75-d4dd-4397-bc87-bd585e5aa5b3", + "sourceSha256": "b929c1b8011fee694624ac6ec7c773c237d423f6a1b03ed0e2787cd707be7144" + }, + { + "materialId": "c733fca0-10f2-4c83-b31b-a1593a9aab48", + "sourceSha256": "859305334a6616ec87757211838ab5faf8e21b649732f79f9266482b836039f0" + }, + { + "materialId": "c7f3122a-d809-4194-a1c9-d84b92fdc463", + "sourceSha256": "21c5826c8549ec755eb0a27eddbda3bc404525233d62492227a5411d59632f56" + }, + { + "materialId": "c8646a39-549d-4d34-becd-331eadf61ed9", + "sourceSha256": "1ac63e0d6bc3b6d475999bef62bbe7a0e9d0a2264c46b1fd226b7fdf0b145445" + }, + { + "materialId": "c95d009f-8ede-4009-879a-f3dcbf52adff", + "sourceSha256": "93cf3283aff993e7deff7765b45582ad975958deee72cbfbbf8d13339c1e3872" + }, + { + "materialId": "c9b3491c-930f-4d67-906f-618ec2650572", + "sourceSha256": "eb8f677158158d3f42cd7fa5e14528988a4e9eb88d1518460ce270cd90bed14e" + }, + { + "materialId": "c9dc9d00-3b96-42d5-9ab5-b2d3f12e75bd", + "sourceSha256": "079e310f90ef10bcdbf67091b176b3e57bc314e7cba8990966d01c7d2672b03a" + }, + { + "materialId": "ca096cb8-a796-4c68-8896-a440cd1bc2cf", + "sourceSha256": "14e29f18ad00bc75d60ceb3e2aa95ef2e29fef66175fa7ace81f634c83834a16" + }, + { + "materialId": "ca105aa3-d38b-405a-a158-3d98778b490e", + "sourceSha256": "23b9a72c61dc5a0024dd0530b20763504912e9d6aaf5c8fe5319b695c604e41e" + }, + { + "materialId": "cbdb6ac7-68a1-4084-a88a-190ba47f1934", + "sourceSha256": "52047b9d1794982883e4c189427d686302353c2f31639fea201a73e1fd8704d8" + }, + { + "materialId": "ccb11856-4aae-4c27-8536-554efd6f27a5", + "sourceSha256": "0c2a8695b1ee424f2a68736985326235b7701b16179920410a912ff8bfd09df1" + }, + { + "materialId": "cd04c31c-6bb0-4add-94bc-dedc92d02a42", + "sourceSha256": "b113451ac91ff5459859c63ecb004cf9617cdb0fa62da7bca87df0418fa7a5c7" + }, + { + "materialId": "cd233d85-1c94-4b02-b446-2f6c1a316b9e", + "sourceSha256": "645e3ecdc39147b4443982f90dbea8bdb725dfde3ef404b0b35b35e2586ff3e1" + }, + { + "materialId": "cda59121-16c5-4809-b67f-14cbdce6c5e9", + "sourceSha256": "9d14eebaaa517d0055310881a42eb1e878beb58d8e59f9e3ffee0f7af21089f2" + }, + { + "materialId": "cdccd1c5-2f2a-4d61-83ee-cead68b84bd0", + "sourceSha256": "37e5ebd84a554f017b8867b2984506f06f7642b78896e8c8452a22092ce3ebe3" + }, + { + "materialId": "cde1bcd6-4430-45bd-8527-266026bf3c18", + "sourceSha256": "139e72c4794e2a3975daf08200284dccee4810564d41d5ffd0b75f3efb9e7d3c" + }, + { + "materialId": "ce020c8e-0124-4612-b828-d9eaafa25bcd", + "sourceSha256": "8890bc29ff6d6b8fdbd02e76cbc289f6874b2c3b1fe274316600703488791376" + }, + { + "materialId": "cf211576-ba25-464e-8d55-b15d9c4b2560", + "sourceSha256": "563c678e1c906c376b4b52e3becebc98690bad4b8f4398949f2c8bc401dd439f" + }, + { + "materialId": "d024f94a-78b2-4b0c-ab44-a003c9ca4722", + "sourceSha256": "bc2411ef45ef1ff1aea9e2d518cdf4294bd9848af164d0d8ed7ee2513540903b" + }, + { + "materialId": "d04d9dfc-6f31-4073-a5a1-993085222746", + "sourceSha256": "21189a50df4195390c368da7a9b52e742912b699cc52d65f81c54ac6e47baf98" + }, + { + "materialId": "d11fb393-ca08-40cc-ae53-de8a2b42ef4f", + "sourceSha256": "43e9c11ab635c0e85cf3e774890d6a18209444115de4ac1627aaaa8d121960c4" + }, + { + "materialId": "d13f8f00-49e1-4230-92a7-98726d924c33", + "sourceSha256": "2f4cd62ac1d27b1c7d9887199e401f36779ad0474306a022370290d15624fc16" + }, + { + "materialId": "d17c4db6-f6b4-4e09-949f-645f67cf7007", + "sourceSha256": "f4e8d2ef0997cec4a5e9918076af3e2630e8025827c12916c77cf45d559cd55f" + }, + { + "materialId": "d23060f1-1805-4d7b-a539-1cab5cd3b0ad", + "sourceSha256": "2b85cf84adbe646d349d488dfb61a5df7fc87d0085871846dd28bf931262a520" + }, + { + "materialId": "d27f5332-38ff-44e2-880e-cf01c81e5e3d", + "sourceSha256": "2afaf5ebf121787e7b6bf612fe0ebfb1cacc4e221d404b940e4f14b2294e6578" + }, + { + "materialId": "d2f0a9a3-f335-47d8-8f3a-2a892b79202d", + "sourceSha256": "73ef2174b4b51a3069466d729fbef96c692cb9676a9f5ae891a6b4b159fc9645" + }, + { + "materialId": "d2fa3da6-d326-4090-a417-a04798a0e8c2", + "sourceSha256": "21ea45f16b8c9fd482a64ab59237d3a6a79e4572194db891c82e2affdcff2b53" + }, + { + "materialId": "d3a4255a-3a06-402b-8f02-1d03286b2b97", + "sourceSha256": "324f0fc17616ae20bc8a6c49d9bf9b2ac6476ace57ee6e7e4a40febd373f1c12" + }, + { + "materialId": "d3a6414d-e6c5-4732-992a-7de95e130a4f", + "sourceSha256": "56c0788e72d7d9968c20492019eb1d2894cb1412d42ae61fdb262681edc4ef36" + }, + { + "materialId": "d3ebfb29-cd52-469b-8ed1-f41ad1d7131b", + "sourceSha256": "d2a36e8c537251179d8c5ce7749293d6e54668cda096cc74b50b465aaed00141" + }, + { + "materialId": "d4531bba-452f-46b1-b276-830b1ce6ea02", + "sourceSha256": "565f166d1090f764e867e72a08cc530bb137f95afc4dce75ab9841b4eeb6e604" + }, + { + "materialId": "d5a51041-2ff6-4e9e-96d7-8d7ae74e9c12", + "sourceSha256": "d9bd1b93de1b00b50ccda9ddc3fd5ef36156f3db4ccc6d104d10fbef4dc00441" + }, + { + "materialId": "d6321a35-5146-43e5-8d56-efef87583a89", + "sourceSha256": "56e693463e0bf2ee4e86b947c1a07ae4b011ceffd85e19f993239e9ddad429fe" + }, + { + "materialId": "d6dd89a5-7e7c-4382-8c0b-1a4ddc460c90", + "sourceSha256": "e75f8a65c42adedc5a8465ad392deff92f18cb885a8ce81d9a716d3e503e0c7b" + }, + { + "materialId": "d7662b0d-f779-45b0-b7b5-c88703118834", + "sourceSha256": "c2e531de76888deee511fe79ab2d535793edbcfb9ae2249ffcf4713ba536237d" + }, + { + "materialId": "d8387da9-7ba0-4c83-9f98-37f8adf4b2ce", + "sourceSha256": "4886a80903c767e6585aa74121caf729b360f30e4d5cddedc7738245806e231a" + }, + { + "materialId": "d9072c2d-bdf6-49b0-b6f6-b6eb75f4265b", + "sourceSha256": "5c2ee4a290b941b6a0d24848e659850d90357588b47d11b0dd77ac1171982f40" + }, + { + "materialId": "daa05d6f-0ab0-41c3-a7f6-042b2f1fbee0", + "sourceSha256": "f06bec20eb60901a25b714dc06e18da8073c52f09064a4a32f7a418d435579b9" + }, + { + "materialId": "dae03318-2063-4a5b-8d23-7cb9c413e457", + "sourceSha256": "3660a50eb4b63d0173dd18efc0a585266b7ffa7febb98e83724ffd0ac5e278a6" + }, + { + "materialId": "db15629a-b36b-4d4d-a4ae-08a4ab6b7049", + "sourceSha256": "d903f012f42d5776123ecc449b28517e1addb6ccdff163c485ecf2a174ca6c2b" + }, + { + "materialId": "db71d005-e553-4c28-aad3-96d0abb11521", + "sourceSha256": "1579fb0090dea6dacb7893bf7701a69c90ff8fb72021722cb2bdbcc9a48fe74e" + }, + { + "materialId": "dd0e0d98-bd81-4cc7-952e-773d986ae9eb", + "sourceSha256": "b5e48a4b4625b5a42186c8713cb6ad3685961b63199eb30debde05745e5bf017" + }, + { + "materialId": "de9bae42-79fa-437b-a63e-9983368ce59d", + "sourceSha256": "12fc2df5eae9a2863f7bb837b2339b4699696819700de5ef0523346e3f71a270" + }, + { + "materialId": "deab31d6-c813-40f4-b837-8ecff2c3437a", + "sourceSha256": "4a5cefd9abefb3b925e933571343277f7f761ed10f048bceb5c8d2cc67b41714" + }, + { + "materialId": "df261b88-c42d-4f00-80b9-c5cf7188dcc6", + "sourceSha256": "14d832582835d59bef63cc57a58714d96ba252244de916fd4f68574fc38f5780" + }, + { + "materialId": "e0b1e0fa-a4b4-4558-951e-4f712d5b56df", + "sourceSha256": "19b6b20cb4e20a9335a1d42a873142ee7204d66afd6fe23f12a18e31033d2d35" + }, + { + "materialId": "e0cf3e56-fab7-4b1a-9939-41d88d4231dd", + "sourceSha256": "449f9c4e61cd7edaea1ecdcf3d27ed235383351871d4ca037749a95f4905b190" + }, + { + "materialId": "e0fc02f6-fa01-4e52-b233-dc61992633c0", + "sourceSha256": "41857c3dafa5cc74407d6065171f51fc1f8dbb231a4d53d8c0b2af2e4f475141" + }, + { + "materialId": "e14fa8f3-66e5-4ab3-b4f8-7bac16c62d31", + "sourceSha256": "05cb338b49ab8ae6baa0e956b5197f16c6fc27ace3d07c0da911762feea1dc97" + }, + { + "materialId": "e16e33f4-4f0a-44bd-9bef-98359be805fd", + "sourceSha256": "6e2f068a53ce6b36aa9c1c794e26949a27382fb9afa6b22c6013ffea19011082" + }, + { + "materialId": "e217282c-f52c-4cf5-8e97-a7bd676ff3d6", + "sourceSha256": "5e7afc50aa01def5364c1c81ce3335643cf0d0fba23d9b090c797cf04e3e5080" + }, + { + "materialId": "e3f65d4c-aa92-49fd-b1dd-0ab39cb24fa3", + "sourceSha256": "ce96041a4929e829b93a653be10e2a4a0dbf73eec5993d9d89c7ce3853e6671a" + }, + { + "materialId": "e428c9e4-15c4-4ba0-80f5-6125e3fa2e2e", + "sourceSha256": "7967aa27202c417694be585d9596c34eb4b77d19d3d596d1432c7881a92a0dd0" + }, + { + "materialId": "e4ab5ed8-25c6-4c2b-bb1d-f73141fcace8", + "sourceSha256": "ec8f514694f593c9a144904b291878fd5fd626ffd6e7557fff98c26fa463208f" + }, + { + "materialId": "e55e32c6-795b-4fa1-b15c-7321c7c4439f", + "sourceSha256": "b6e738d987c5a3a958ed0b09c1973e8d71c734db35021c7b5d13b1949338ad46" + }, + { + "materialId": "e6655fe5-21ab-419d-adc2-5b81833a50e8", + "sourceSha256": "7f3a65e13e1084aa0b68eb313926603b91d8c557a889f0af369b3e4b5370030b" + }, + { + "materialId": "e6dbff88-652f-4a3b-b619-640a49ef1f08", + "sourceSha256": "10d37361b5b1ec8b4e047c1300ccd7a9eb27c8ec3060d0fcb6fdf7ad2e57a001" + }, + { + "materialId": "e727128f-3d80-467b-9a39-c6550f308877", + "sourceSha256": "91a268973710d36da9340caa05949ac649b4c9f26f92b1188783bf7026b5f486" + }, + { + "materialId": "e77bc201-9bb5-43fd-a701-0df6ed66fe5a", + "sourceSha256": "6d770ec7d263213b2b8ea0511a30baba2d571124c5f286f23b1dc52f39cba756" + }, + { + "materialId": "e7cc094b-0373-48a0-a49e-14403a296b98", + "sourceSha256": "d438c36f14217cbc67a7dc13ce4a5e3a4f66d000cfde12c2f9f3eaf0cc1a156f" + }, + { + "materialId": "e8001d37-877a-41a8-9825-3eeb12f4d409", + "sourceSha256": "1dd8961017945a6797fbe5cfaf95e34d15b6f590adf26babaacae2c6f654d25f" + }, + { + "materialId": "e88f1405-e475-44fd-b3a7-e107550b8a53", + "sourceSha256": "9dd286cb9da930b1ef2519b25d6676b51bdc96f61562088cc838ceb8a6e28117" + }, + { + "materialId": "e91dcb5e-0e2e-4730-b60f-fc6cb9ffe504", + "sourceSha256": "b5dc5258e82bf3050607ab3df0cc601dc15792ef1a95654003ca79e824a7dddc" + }, + { + "materialId": "ea0666b6-5837-4d95-b9b4-9fae3492b2b7", + "sourceSha256": "b6135f273037386e9e248d1a471b9198872c6317a22d35052ef59eb0971a0969" + }, + { + "materialId": "ea3152ad-98ab-47a1-9b60-5f8b4f2eac6e", + "sourceSha256": "6ca7d3262dbdaa11c012ae9e9259a10bcf1515cd6af5d661711bb4028fa12959" + }, + { + "materialId": "eac3cde0-a741-4e4a-bf5d-e00d42ba61bd", + "sourceSha256": "a9a30e622201d4bb3be9a0dc0b94d6fd82fedaa14994e0188cfde640b0b2d94e" + }, + { + "materialId": "ec4bfc54-30a1-4a89-8386-057809a8a206", + "sourceSha256": "b4583195151b5d32449c48240dcd291364a13f4266d88537093f9d0ff7c49dc5" + }, + { + "materialId": "ed056361-da4f-4bd7-b699-644c5d437bba", + "sourceSha256": "fdd97be0cc0a8af0802e7c63878b1ba47ea32a181aa0646db34222605e8726c9" + }, + { + "materialId": "ed32e26c-ef4e-41d5-93f9-b6d92556b6c0", + "sourceSha256": "de124d5f26f35a3f6c5b416e0458b667febc820b9552400c3a3f976d18667d35" + }, + { + "materialId": "ed7b3871-636d-4e51-b2d8-9d304b407bed", + "sourceSha256": "557939c6c0405672e940ff8faba89ba16e160dad06d45c586bb513e72fbab89e" + }, + { + "materialId": "edefc259-df45-43ed-8ed8-c24b76a13ab7", + "sourceSha256": "62472e42b0b1fe04edeae7b46dd17f21ea470077c27843d05fd66c140d6890b7" + }, + { + "materialId": "ee64debb-7a95-44c0-b4de-592318c627c1", + "sourceSha256": "7c1e6aee33ec61b641c69d894549414ae0ccc26fb947cb67313e3c61801b2b1b" + }, + { + "materialId": "eeca10ba-ea32-45a5-bc53-229408631132", + "sourceSha256": "068f8e8a6dbc4bbd210b1ad4a9931cb6d6c08a87a6e01976778d28a9d8a78b88" + }, + { + "materialId": "ef6d9ebc-fa4b-4bb5-b29f-44c5406238a2", + "sourceSha256": "ccb4ed9cb9ff577b51072cd065a9056a39cd70fd9b8a84d0bae6985978e6309f" + }, + { + "materialId": "ef7cef14-5561-468c-b9c9-1a6d6883aee2", + "sourceSha256": "065a7548fc15a1e66ff708fe94a4cde3e5486d92faaddc40f7923f0e00540a3f" + }, + { + "materialId": "ef908b80-4fdf-4bd9-980d-133640585bf9", + "sourceSha256": "9a5455b7fa3b8ed549683104abecdf507a59ad36475fc827964ab87acfc94e15" + }, + { + "materialId": "f0964750-5b15-4fd3-9497-c212764ee0cd", + "sourceSha256": "3f5cea6b294eebb46910820efa72c494373c9d8debc756600f4a103ad5066640" + }, + { + "materialId": "f10f2c6a-be23-492d-9b2f-83e1454a17e6", + "sourceSha256": "07784639005a91352785c80d5f4633051c6bb47c0583fc99455d0e665637623d" + }, + { + "materialId": "f1bbfb67-07e4-45b4-b173-f4e65a61cab7", + "sourceSha256": "0134d9959c40163b88eacf05ffe5df4a61d3c2bfd8d0361ec8651257090a51cf" + }, + { + "materialId": "f1eee192-2f9f-4522-be46-d8ab3e7b4d72", + "sourceSha256": "fd2da3d57aef21098dc1820f8072ffbdcba4c2578326ce8282b7d723670e0a44" + }, + { + "materialId": "f2134231-7f75-483a-944d-44803df2ceef", + "sourceSha256": "173f5dfb3858fe398eba4bb9c1902f6dfa24d0439c49801b02e58796f79c0214" + }, + { + "materialId": "f26d69a4-8c3a-44b5-a642-b967f75bf4bb", + "sourceSha256": "f717ead394d95c5d0740a32f6628e4771190944f6e060a83506b19812624fa13" + }, + { + "materialId": "f2e70bb9-2034-4bb4-97f8-4565b4d667ca", + "sourceSha256": "f8bed2485735e07fa2825e535021cdc74f3eed63e482f70172f7377691266b55" + }, + { + "materialId": "f34e79c3-b83a-428c-a6cb-ee10c1e261c8", + "sourceSha256": "e454c59c1b78ed2e73a6a85739903e0727ea8e21490bf48b98ec453c0dc9c494" + }, + { + "materialId": "f3947ee5-e5aa-4b25-83ea-a5d70e30e0a7", + "sourceSha256": "44c3d13fc9a6001ddd251891ffa848924f3a16a68c5354baf43ee5a6c599789a" + }, + { + "materialId": "f4342715-62b0-4db1-8206-0361f23ab98d", + "sourceSha256": "47dee4ac1ec51e9b5b348b053b9bf13b7970e9bf7dfe2707d310cac41717571f" + }, + { + "materialId": "f4810986-3bed-41ec-b572-588ec19bf723", + "sourceSha256": "9c8265fb2f6eb7463c57f3eb8db2677daee9ab36c3778ec96b71111ccc34ca61" + }, + { + "materialId": "f5a076de-3650-4bfa-b6c8-0897d44368bd", + "sourceSha256": "7277b370d5facd5c9c77632ee9fa7e32eeeec59d76082a10a53681a4bda06d28" + }, + { + "materialId": "f5a32470-43fd-44ac-94ae-d3e51c7cebc6", + "sourceSha256": "2690761623c05a5a87f7be141902567532d87d823ce1087fbbdb238118cda4a2" + }, + { + "materialId": "f5acedee-ffd2-40a0-9ad4-55f9ea5b41a7", + "sourceSha256": "20a69388b379a86d5f489d4a5159c59e74cd67d8c6342a4bc9ca770d0619e8be" + }, + { + "materialId": "f5ec2020-c514-4ca0-a722-0fa441e81a7e", + "sourceSha256": "4b7279f0984f524a897627e48e64f559f360239670792bfc5faae7e5add56e61" + }, + { + "materialId": "f6a70106-9c49-487a-99fb-2087b2326b6e", + "sourceSha256": "75076b00939b1d1af2fc8596065538423a0f83d9a40e0c7f8f16b7cd3e7af52b" + }, + { + "materialId": "f706a89d-3212-47e0-8bca-8065cb18e0d5", + "sourceSha256": "c4de52730c01d03ab8f011101a45e586f59e606c0b2915daa080a581c4816c70" + }, + { + "materialId": "f74c19b4-2624-4d08-bf4c-1b57a5ef0e9b", + "sourceSha256": "ced198788158227a346dd6a90ae3ffd3bca2b16c4512ed9ff3ba33c5da81777f" + }, + { + "materialId": "f859aa7f-7874-409f-8d5f-951113817023", + "sourceSha256": "514c7ede18d13050e133f7cf386062b6c55c25425dd9f91599a634e3687f0cf9" + }, + { + "materialId": "f873e5fd-d7be-4289-b3da-fd28eb183bc7", + "sourceSha256": "f9654cb16d455d4f4cd6b366411914a26ccbfca33c998563b71e37614a028279" + }, + { + "materialId": "f8c5e4b2-9c2e-47a5-86d9-382a3d7d81fc", + "sourceSha256": "7e4b3539f9ce173090cc1f68977ca06daa4c8b30e7d06f3906c8685e5bbc8f66" + }, + { + "materialId": "faace757-6a93-40fa-af3d-cad56b9c3017", + "sourceSha256": "2c461033f4d54641987f2a27bd1496117766cc9dc19b1c29e69a18569abe4c79" + }, + { + "materialId": "faf8b47c-a85f-431b-be4f-6310ea68a909", + "sourceSha256": "7dee046fa31410614d4f15f6934d411abb2ef885b71e572bdd00f37f989289a1" + }, + { + "materialId": "fb417109-9223-46af-ada1-790ee345701b", + "sourceSha256": "decd2cd685f607f0c105c80be4dd46faf9654f18dbba774f9e610fdbd753aa69" + }, + { + "materialId": "fb4e27cc-3f26-45f2-b9c4-986978adabea", + "sourceSha256": "111f25a35819370acbf728a8584ac1079e72a35c72e21e6715a5bb8a8e035f30" + }, + { + "materialId": "fccbd798-4742-4733-8b27-079c9d0a7293", + "sourceSha256": "71169c94ecdd557ad386b1cd5758c2210ed4fc372cd2f9a9f167ca11d168392e" + }, + { + "materialId": "fdd564f5-a989-4398-9d61-db87ad1ee7af", + "sourceSha256": "0381c12ed3c3ee6b7b4fef6cc9397e03f4013b05d26ad770d383c96a18d7ce28" + }, + { + "materialId": "fe21e1d3-c034-4f32-97be-e7d8ea61b1b8", + "sourceSha256": "ead073078e81dab9597ebf9b6134e20f0017bf8c55e95bd89b76d9aac06c3336" + }, + { + "materialId": "ff62362c-889c-4cef-a2d7-c17f26d0b739", + "sourceSha256": "6c18f1af9fde226f78c88003133001ac24267d2e2539a709d6628ba4461dcbb1" + } + ] +} diff --git a/scripts/git-incremental-deploy-macos.mjs b/scripts/git-incremental-deploy-macos.mjs index 2d88dc3b..40e7c36e 100755 --- a/scripts/git-incremental-deploy-macos.mjs +++ b/scripts/git-incremental-deploy-macos.mjs @@ -12,6 +12,7 @@ const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)) const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '..') const POWERSHELL_DEPLOY_SCRIPT = path.join(SCRIPT_DIRECTORY, 'git-incremental-deploy.ps1') const PROXY_RECONCILER = path.join(SCRIPT_DIRECTORY, 'local-proxy-reconcile.sh') +const DESKTOP_ADMISSION = path.join(SCRIPT_DIRECTORY, 'desktop-release-admission.py') const TRUSTED_REPOSITORY = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' const TRUSTED_BRANCH = 'main' @@ -330,6 +331,9 @@ async function main() { const targetCommit = await resolveAndScanExactCommit(options) const powerShellSource = await fs.readFile(POWERSHELL_DEPLOY_SCRIPT, 'utf8') const proxyReconcilerBase64 = (await fs.readFile(PROXY_RECONCILER)).toString('base64') + const admissionSource = (await fs.readFile(DESKTOP_ADMISSION, 'utf8')).replaceAll('\r\n', '\n').replaceAll('\r', '\n') + const admissionDigest = createHash('sha256').update(admissionSource).digest('hex') + const entryDigest = createHash('sha256').update(powerShellSource.replaceAll('\r\n', '\n').replaceAll('\r', '\n')).digest('hex') const values = { __REPO_URL__: TRUSTED_REPOSITORY, @@ -359,6 +363,8 @@ async function main() { __S3_PUBLIC_DOMAIN__: 'https://s3.jiansun.vip/gongxiangpan', __DOWNLOAD_TOKEN_SECRET__: process.env.DOWNLOAD_TOKEN_SECRET || '', __PROXY_RECONCILE_SCRIPT_B64__: proxyReconcilerBase64, + __DESKTOP_ADMISSION_SHA256__: admissionDigest, + __DEPLOY_ENTRY_SHA256__: entryDigest, __DRY_RUN__: options.dryRun ? '1' : '0', __NO_CACHE__: options.noCache ? '1' : '0', __SKIP_BUILD__: '0', diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 467baff0..39870e9a 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -697,6 +697,17 @@ $proxyReconcilerText = [System.IO.File]::ReadAllText($proxyReconcilerPath) $proxyReconcilerText = $proxyReconcilerText.Replace("`r`n", "`n").Replace("`r", "`n") $proxyReconcilerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($proxyReconcilerText) $proxyReconcilerBase64 = [Convert]::ToBase64String($proxyReconcilerBytes) +$admissionPath = Join-Path $PSScriptRoot 'desktop-release-admission.py' +if (-not (Test-Path -LiteralPath $admissionPath -PathType Leaf)) { + throw 'Desktop release admission policy is missing; deployment is blocked.' +} +$admissionText = [System.IO.File]::ReadAllText($admissionPath).Replace("`r`n", "`n").Replace("`r", "`n") +$entryText = [System.IO.File]::ReadAllText($PSCommandPath).Replace("`r`n", "`n").Replace("`r", "`n") +$digestAlgorithm = [System.Security.Cryptography.SHA256]::Create() +try { + $admissionDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($admissionText))).Replace('-', '').ToLowerInvariant() + $entryDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($entryText))).Replace('-', '').ToLowerInvariant() +} finally { $digestAlgorithm.Dispose() } $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl @@ -726,6 +737,8 @@ $tokens = @{ '__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain '__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret '__PROXY_RECONCILE_SCRIPT_B64__' = ConvertTo-ShellSingleQuoted $proxyReconcilerBase64 + '__DESKTOP_ADMISSION_SHA256__' = ConvertTo-ShellSingleQuoted $admissionDigest + '__DEPLOY_ENTRY_SHA256__' = ConvertTo-ShellSingleQuoted $entryDigest '__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' })) '__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' })) '__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' })) @@ -1052,6 +1065,11 @@ if [ "$SKIP_DEPLOY" != "1" ]; then fi ok "Production image revision label matches $COMMIT_SHA" + # Bundle from the exact fetched checkout, not workstation WIP or old image files. + MATERIAL_PREVIEW_GATE_MODULE="$(node scripts/material-preview-release-gate.mjs bundle)" || fail "Material preview release gate could not be bundled from the exact checkout" + [[ "$MATERIAL_PREVIEW_GATE_MODULE" = data:text/javascript\;base64,* ]] || fail "Material preview release gate bundle is invalid" + [ "${#MATERIAL_PREVIEW_GATE_MODULE}" -le 524288 ] || fail "Material preview gate bundle exceeds its fixed transport budget" + IFS= read -r -d '' PROD_PAYLOAD <<'PROD_SCRIPT' || true set -Eeuo pipefail @@ -1080,7 +1098,10 @@ MIGRATION_AUDIT_SOURCE_COMMIT="$2" REQUIRE_MIGRATION_AUDIT="$3" DEPLOYMENT_STRATEGY="${4:-direct}" DETAIL_PRIVATE_MOUNT="/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" -DETAIL_FEATURE_ENABLED=false +DETAIL_FEATURE_ENABLED=uninitialized +DESKTOP_ADMISSION="/mnt/PoolOne/apps/youyidian-3d/release-policy/desktop-admission/guard.py" +DESKTOP_ADMISSION_SHA256=__DESKTOP_ADMISSION_SHA256__ +DEPLOY_ENTRY_SHA256=__DEPLOY_ENTRY_SHA256__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } @@ -1091,6 +1112,10 @@ create_container() { local name="$1" local image="$2" local publish_host_port="${3:-0}" + case "${DETAIL_FEATURE_ENABLED:-}" in + true|false) ;; + *) warn "Detail feature configuration was not prepared; no container was created"; return 1 ;; + esac local deploy_version local transcode_instance_id local sanitized_env_file @@ -1200,9 +1225,23 @@ command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" command -v systemctl >/dev/null 2>&1 || fail "Missing systemctl on production server" command -v sha256sum >/dev/null 2>&1 || fail "Missing sha256sum on production server" command -v timeout >/dev/null 2>&1 || fail "Missing timeout on production server" +command -v python3 >/dev/null 2>&1 || fail "Missing python3 on production server; desktop admission cannot be verified" require_file "$ENV_FILE_PATH" +require_desktop_release_admission() { + [ -f "$DESKTOP_ADMISSION" ] && [ ! -L "$DESKTOP_ADMISSION" ] || return 1 + [ "$(stat -c '%u:%g:%a' "$DESKTOP_ADMISSION")" = "0:0:555" ] || return 1 + [ "$(sha256sum "$DESKTOP_ADMISSION" | awk '{print $1}')" = "$DESKTOP_ADMISSION_SHA256" ] || return 1 + # This drop-in survives an older workstation rewriting the base service. + # Require the real server reload path to invoke admission before nginx. + local reload="" + reload="$(systemctl show "${CONTAINER_NAME}-local-proxy.service" -p ExecReload --value)" || return 1 + case "$reload" in *"$DESKTOP_ADMISSION"*"/usr/sbin/nginx"*) ;; *) return 1 ;; esac +} + +require_desktop_release_admission || fail "Server desktop admission gate is missing or outdated; no container was stopped. Install the reviewed project-scoped policy before releasing." + detail_private_mount_is_safe() { [ "$DETAIL_PRIVATE_MOUNT" = "/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" ] || return 1 case "${DETAIL_PRIVATE_MOUNT}/" in "${UPLOADS_MOUNT}/"*) return 1 ;; esac @@ -1810,6 +1849,104 @@ verify_candidate_brand_and_storage_dependencies() { verify_candidate_configured_brand_assets "$name" "$candidate_ip" || return 1 } +# Every real release uses the same old/candidate/post probe plan. The only +# mutable state is this project's private audit directory; no material, download, +# bucket, or database value is written. A missing first baseline stops deployment. +prepare_material_preview_release() { + MATERIAL_PREVIEW_AUDIT_ROOT="${PROXY_ROOT}/material-preview-health" + [ -n "${MATERIAL_PREVIEW_GATE_MODULE:-}" ] || return 1 + [ -d "$MATERIAL_PREVIEW_AUDIT_ROOT" ] && [ ! -L "$MATERIAL_PREVIEW_AUDIT_ROOT" ] || return 1 + [ -f "$MATERIAL_PREVIEW_AUDIT_ROOT/accepted-pointer.json" ] && [ ! -L "$MATERIAL_PREVIEW_AUDIT_ROOT/accepted-pointer.json" ] || return 1 + MATERIAL_PREVIEW_AUDIT_RUN="$(mktemp -d "${MATERIAL_PREVIEW_AUDIT_ROOT}/release-${COMMIT_SHA:0:12}-XXXXXXXX")" || return 1 + chmod 700 "$MATERIAL_PREVIEW_AUDIT_RUN" || return 1 + printf '%s' "$MATERIAL_PREVIEW_GATE_MODULE" >"$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" || return 1 + chmod 600 "$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" || return 1 + python3 - "$MATERIAL_PREVIEW_AUDIT_ROOT" "$MATERIAL_PREVIEW_AUDIT_RUN" <<'PY' +import hashlib, json, os, pathlib, re, sys +root, run = map(pathlib.Path, sys.argv[1:]) +try: + pointer_raw = (root / 'accepted-pointer.json').read_bytes() + pointer = json.loads(pointer_raw) + relative = pointer['file'] + if pointer.get('version') != 1 or not re.fullmatch(r'(?:bootstrap-[A-Za-z0-9._-]+/bootstrap|release-[A-Za-z0-9._-]+/post)\.json', relative): raise ValueError() + if not re.fullmatch(r'[0-9a-f]{64}', pointer['sha256']): raise ValueError() + source = root / relative + if source.is_symlink() or source.parent.is_symlink() or not source.is_file() or source.resolve().parent.parent != root.resolve(): raise ValueError() + raw = source.read_bytes() + if len(raw) > 32 * 1024 * 1024 or hashlib.sha256(raw).hexdigest() != pointer['sha256']: raise ValueError() + for filename, content in [('accepted.json', raw), ('accepted-pointer-before.json', pointer_raw)]: + with open(run / filename, 'xb') as f: f.write(content) + os.chmod(run / filename, 0o600) +except Exception: + sys.stderr.write('Reviewed material preview baseline missing, changed, or outside its private audit root\n') + sys.exit(1) +PY +} + +run_material_preview_release_gate() { + local name="$1" phase="$2" output_file="${MATERIAL_PREVIEW_AUDIT_RUN}/${2}.json" status=0 + case "$phase" in capture|candidate|post) ;; *) return 1 ;; esac + [ ! -e "$output_file" ] && [ -f "$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" ] || return 1 + # The program and input travel on stdin; no credentials or large JSON appear in + # process arguments, environment variables, the image filesystem, or logs. + if ! python3 - "$MATERIAL_PREVIEW_AUDIT_RUN" "$phase" "$COMMIT_SHA" "$S3_PUBLIC_DOMAIN" "$ENV_FILE_PATH" "$PUBLIC_APP_URL" <<'PY' | docker exec -i -w /app "$name" node --input-type=module - >"$output_file" +import json, pathlib, sys +run = pathlib.Path(sys.argv[1]); phase, commit, public_domain, env_file, app_url = sys.argv[2:] +read = lambda name: json.loads((run / (name + '.json')).read_bytes()) +keys = ['S3_ENDPOINT','S3_BUCKET','S3_FILE_PATH','S3_PUBLIC_DOMAIN','S3_REGION','SERVER_IP','MINIO_API_PORT','APP_PUBLIC_URL'] +target = {key: '' for key in keys}; seen = set() +for line in pathlib.Path(env_file).read_text().splitlines(): + if not line.strip() or line.lstrip().startswith('#') or '=' not in line: continue + key, value = line.split('=', 1); key = key.strip() + if key not in target or key in ('S3_PUBLIC_DOMAIN','APP_PUBLIC_URL'): continue + if key in seen: raise ValueError('duplicate nonsecret delivery key') + seen.add(key); target[key] = value +target['S3_PUBLIC_DOMAIN'] = public_domain; target['APP_PUBLIC_URL'] = app_url +payload = dict(version=1, stage=phase, commit=commit, accepted=read('accepted'), targetDeliveryConfig=target) +if phase in ('candidate', 'post'): payload['capture'] = read('capture') +if phase == 'post': payload['candidate'] = read('candidate') +print('const {runReleaseStage}=await import(' + json.dumps((run / 'gate-module.url').read_text()) + ');') +print('try { const r=await runReleaseStage(' + json.dumps(payload, separators=(',', ':')) + '); console.log(JSON.stringify(r)); process.exitCode=r.passed?0:1; } catch(e) { console.log(JSON.stringify({version:1,readOnly:true,passed:false,stage:' + json.dumps(phase) + ',code:typeof e?.code==="string"&&/^[a-z_]+$/.test(e.code)?e.code:"release_preview_detector_failed"}));process.exitCode=1; }') +PY + then status=1; fi + chmod 600 "$output_file" || return 1 + if ! python3 - "$output_file" "$phase" <<'PY' +import json, sys +try: + r = json.load(open(sys.argv[1])); phase = sys.argv[2] + if r.get('stage') != phase or r.get('passed') is not True or r.get('readOnly') is not True: + sys.stderr.write('Material preview release gate failed; retain the private receipt for diagnosis\n'); sys.exit(1) + print('[OK] Material preview '+phase+': '+str(len(r['inventory']))+' metadata rows, '+str(len(r['checks']))+' fixed probes, '+str(len(r['knownFailures']))+' known failures retained, '+str(len(r['plan']['unverified']))+' explicitly unverified rows') +except Exception: + sys.stderr.write('Material preview release receipt is missing or incomplete\n'); sys.exit(1) +PY + then status=1; fi + [ "$status" = 0 ] || return 1 +} + +accept_material_preview_release() { + # Promote only after the complete existing release acceptance, using one atomic + # pointer. Immutable pre/post receipts and the previous pointer remain intact. + python3 - "$MATERIAL_PREVIEW_AUDIT_ROOT" "$MATERIAL_PREVIEW_AUDIT_RUN" "$COMMIT_SHA" <<'PY' +import hashlib, json, os, pathlib, sys +root, run = map(pathlib.Path, sys.argv[1:3]); commit = sys.argv[3] +try: + if (root / 'accepted-pointer.json').read_bytes() != (run / 'accepted-pointer-before.json').read_bytes(): raise ValueError() + reports = [json.loads((run / (stage + '.json')).read_bytes()) for stage in ('capture', 'candidate', 'post')] + for stage, report in zip(('capture', 'candidate', 'post'), reports): + if report.get('stage') != stage or report.get('commit') != commit or report.get('passed') is not True: raise ValueError() + if len({r['planSha256'] for r in reports}) != 1 or len({r['inventorySha256'] for r in reports}) != 1 or len({r['metadataAudit']['inventorySha256'] for r in reports}) != 1: raise ValueError() + raw = (run / 'post.json').read_bytes() + pointer = dict(version=1, file=str((run / 'post.json').relative_to(root)), sha256=hashlib.sha256(raw).hexdigest()) + temporary = run / 'accepted-pointer.new' + with open(temporary, 'xb') as f: + os.chmod(temporary, 0o600); f.write((json.dumps(pointer, separators=(',', ':'))+'\n').encode()); f.flush(); os.fsync(f.fileno()) + os.replace(temporary, root / 'accepted-pointer.json') +except Exception: + sys.stderr.write('Material preview baseline was not promoted; previous release evidence retained\n'); sys.exit(1) +PY +} + verify_ready_bootstrap_handoff_evidence() { local handoff="$1" local evidence_name="" @@ -2452,6 +2589,8 @@ EOF } switch_proxy() { + require_desktop_release_admission || return 1 + python3 "$DESKTOP_ADMISSION" --attest-config --entry-sha256 "$DEPLOY_ENTRY_SHA256" || return 1 ensure_proxy_service || return 1 if is_proxy_running; then systemctl reload "$PROXY_SERVICE" @@ -2836,6 +2975,8 @@ direct_replace_active_container() { [[ "$previous_image" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Active container immutable image could not be determined: $name" verify_container_uploads_storage "$name" || fail "Active container uploads storage could not be verified before direct replacement" verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA" || fail "Direct replacement image label does not match the exact Git commit" + prepare_material_preview_release || fail "Reviewed material preview baseline is unavailable; active container was not stopped" + run_material_preview_release_gate "$name" capture || fail "Material preview pre-release gate failed; active container was not stopped" prepare_worker_runtime_directories || fail "Worker runtime directories could not be prepared" prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid" @@ -2883,6 +3024,9 @@ direct_replace_active_container() { if ! verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"; then fail_direct_replace_with_restore "New direct container cannot authenticate to S3 or decode configured brand images" "$name" "$previous_image" fi + if ! run_material_preview_release_gate "$name" candidate; then + fail_direct_replace_with_restore "Material previews regressed or inventory changed before traffic switch" "$name" "$previous_image" + fi if ! wait_for_direct_worker_capabilities "$name"; then fail_direct_replace_with_restore "New direct container workers did not become ready within the bounded startup window" "$name" "$previous_image" fi @@ -2898,7 +3042,11 @@ direct_replace_active_container() { if ! write_proxy_config "${replacement_ip}:3000" "$name" || ! switch_proxy || ! verify_proxy_target "$name" "$COMMIT_SHA"; then fail_direct_replace_with_restore "New direct container could not become the verified proxy target" "$name" "$previous_image" fi + if ! run_material_preview_release_gate "$name" post; then + fail_direct_replace_with_restore "Public material preview post-release gate failed" "$name" "$previous_image" + fi write_current_container "$name" || fail "Direct replacement traffic is healthy but the current-slot record could not be written" + accept_material_preview_release || fail_direct_replace_with_restore "Material preview accepted baseline could not be atomically recorded" "$name" "$previous_image" retire_inactive_direct_slot "$name" || warn "The inactive historical slot could not be removed; the active direct container remains healthy" rm -f -- "$(transcode_drain_file "$name")" "$(task_detail_drain_file "$name")" || true ok "Direct replacement completed: $name" @@ -3725,6 +3873,10 @@ fi ensure_public_traffic_rollup_token +[ -n "$CURRENT_CONTAINER" ] || fail "Material preview baseline requires an existing verified active container" +prepare_material_preview_release || fail "Reviewed material preview baseline is unavailable; traffic was not changed" +run_material_preview_release_gate "$CURRENT_CONTAINER" capture || fail "Material preview pre-release gate failed; traffic was not changed" + TRANSCODE_DRAIN_SOURCES="" if [ -n "$CURRENT_CONTAINER" ]; then TRANSCODE_DRAIN_SOURCES="$CURRENT_CONTAINER" @@ -3736,6 +3888,11 @@ if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then esac fi +# Resolve the approved env-file in the normal blue-green path as well as direct +# replacement. Otherwise its initial value silently disables desktop access and +# omits the private persistent mount, even when production configuration is true. +prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid; no candidate was created" + # 蓝绿入口和意外残留的直连容器都可能运行 worker;必须逐个证明当前协议、 # 设置 drain 并等待 active 消失。任一状态不明都保留原容器并停止发布。 for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do @@ -3849,6 +4006,9 @@ fi if ! verify_candidate_brand_and_storage_dependencies "$NEXT_CONTAINER" "$NEXT_IP"; then fail_candidate_before_traffic "Candidate cannot authenticate to S3 or fully decode its configured and baked brand images; traffic was not switched" fi +if ! run_material_preview_release_gate "$NEXT_CONTAINER" candidate; then + fail_candidate_before_traffic "Material previews regressed or inventory changed; traffic was not switched" +fi if ! verify_video_transcode_capability "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate did not publish the required video transcode drain capability" fi @@ -3886,6 +4046,10 @@ if [ "$SWITCH_REPORTED_FAILURE" = "1" ]; then warn "Proxy switch command reported failure, but the runtime candidate target was independently verified" fi +if ! run_material_preview_release_gate "$NEXT_CONTAINER" post; then + abort_after_uncertain_switch "Public material preview post-release gate failed" +fi + if ! write_current_container "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate traffic was verified but the committed target record could not be written" fi @@ -4033,6 +4197,8 @@ const fs = require("fs"); ok "Public commit, target, database, Redis, authenticated S3, and all Logo images passed acceptance" fi +accept_material_preview_release || abort_after_uncertain_switch "Material preview accepted baseline could not be atomically recorded" + [ -z "$READY_BOOTSTRAP_HANDOFF" ] || { mv -- "$READY_BOOTSTRAP_HANDOFF/handoff.ready" "$READY_BOOTSTRAP_HANDOFF/consumed" || \ abort_after_uncertain_switch "Bootstrap handoff could not be atomically consumed after acceptance" @@ -4041,7 +4207,7 @@ fi } PROD_SCRIPT - printf '%s' "$PROD_PAYLOAD" | prod_ssh_script \ + printf '%s\n%s' "MATERIAL_PREVIEW_GATE_MODULE=$(printf '%q' "$MATERIAL_PREVIEW_GATE_MODULE")" "$PROD_PAYLOAD" | prod_ssh_script \ "$TARGET" "$CONTAINER_NAME" "$PORT" "$REGISTRY_COMMIT" "$EXPECTED_IMAGE_ID" "$COMMIT_SHA" \ "/mnt/PoolOne/apps/youyidian-3d/$ENV_FILE" "$PUBLIC_APP_URL" "$CORS_ALLOWED_ORIGINS" \ "$DINGTALK_CALLBACK_ORIGIN" "$DINGTALK_INTERNAL_RETURN_ORIGINS" "$S3_PUBLIC_DOMAIN" \ diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index dc5b57ed..a520d018 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -27,6 +27,41 @@ test('generated remote deployment payload is valid bash', () => { assert.equal(result.status, 0, result.stderr || result.stdout) }) +test('server admission blocks proxy changes before any reload or unit rewrite', () => { + for (const phase of ['missing', 'unattested']) { + const input = `set -eu +DESKTOP_ADMISSION=/project/guard.py +DEPLOY_ENTRY_SHA256=${'a'.repeat(64)} +require_desktop_release_admission() { ${phase === 'missing' ? 'return 1' : 'return 0'}; } +python3() { printf 'attestation-rejected\\n'; return 1; } +ensure_proxy_service() { printf 'UNEXPECTED_UNIT_WRITE'; } +is_proxy_running() { return 0; } +systemctl() { printf 'UNEXPECTED_RELOAD'; } +${shellFunction('switch_proxy')} +switch_proxy +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.doesNotMatch(result.stdout + result.stderr, /UNEXPECTED_/) + assert.equal(result.stdout.includes('attestation-rejected'), phase === 'unattested') + } +}) + +test('both publishing clients pin the same server policy and persist a canonical entry digest', () => { + const source = deploySource() + const mac = readFileSync(new URL('./git-incremental-deploy-macos.mjs', import.meta.url), 'utf8') + for (const token of ['__DESKTOP_ADMISSION_SHA256__', '__DEPLOY_ENTRY_SHA256__']) { + assert.ok(source.includes(token)) + assert.ok(mac.includes(token)) + } + const requireGuard = shellFunction('require_desktop_release_admission') + assert.match(requireGuard, /sha256sum "\$DESKTOP_ADMISSION"/) + assert.match(requireGuard, /0:0:555/) + assert.match(requireGuard, /systemctl show .*ExecReload/) + assert.match(source, /require_desktop_release_admission \|\| fail "Server desktop admission gate is missing/) + assert.match(shellFunction('switch_proxy'), /--attest-config --entry-sha256 "\$DEPLOY_ENTRY_SHA256"/) +}) + test('Mac entry renders every canonical remote deployment token', () => { const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') const template = extractRemoteTemplate(source) @@ -127,6 +162,69 @@ function shellFunction(name) { return source.slice(start, end + 3) } +test('preview inventory and fixed probes guard direct stop, switch, and baseline promotion in both wrappers', () => { + const direct = shellFunction('direct_replace_active_container') + const stop = direct.indexOf('docker stop --time 10') + const switchTraffic = direct.indexOf('write_proxy_config "${replacement_ip}:3000"') + const record = direct.indexOf('write_current_container "$name"') + assert.ok(direct.indexOf('prepare_material_preview_release') < stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" capture') < stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" candidate') > stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" candidate') < switchTraffic) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" post') > switchTraffic) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" post') < record) + assert.ok(direct.indexOf('accept_material_preview_release') > record) + const payload = extractRemoteTemplate(deploySource()) + assert.match(payload, /node scripts\/material-preview-release-gate\.mjs bundle/) + assert.match(payload, /run_material_preview_release_gate "\$NEXT_CONTAINER" candidate/) + assert.match(payload, /run_material_preview_release_gate "\$NEXT_CONTAINER" post/) + assert.match(payload, /MATERIAL_PREVIEW_GATE_MODULE=\$\(printf '%q'/) + assert.doesNotMatch(payload, /SKIP_MATERIAL_PREVIEW|IGNORE_MATERIAL_PREVIEW|ALLOW_PREVIEW_FAILURE/) +}) + +test('real direct shell control flow stops before replacement or uses existing rollback exactly once on preview regression', () => { + const direct = shellFunction('direct_replace_active_container') + const inert = ['verify_container_uploads_storage', 'verify_image_revision_label', 'prepare_worker_runtime_directories', 'prepare_detail_generator_release', + 'create_container', 'verify_container_uploads_mount_metadata', 'verify_container_upload_dir_config', 'verify_container_baked_revision', + 'verify_transcode_lease_schema', 'verify_task_detail_upload_lease_schema', 'verify_detail_generator_schema', 'verify_detail_generator_dependencies', + 'verify_brand_logo_defaults', 'verify_material_upload_idempotency_schema', 'verify_candidate_database_identity', 'verify_candidate_brand_and_storage_dependencies', + 'wait_for_direct_worker_capabilities', 'verify_video_transcode_capability', 'verify_task_detail_upload_capability', 'verify_proxy_target', 'retire_inactive_direct_slot'] + for (const phase of ['capture', 'candidate', 'post', 'none']) { + const input = `set -u +BLUE_CONTAINER=material-blue +GREEN_CONTAINER=material-green +REGISTRY_COMMIT=registry/exact-image +COMMIT_SHA=${'a'.repeat(40)} +log() { :; } +ok() { :; } +warn() { :; } +fail() { printf 'blocked:%s\n' "$*"; exit 82; } +docker() { case "$1" in inspect) printf 'sha256:${'b'.repeat(64)}\n' ;; *) printf 'docker:%s\n' "$1" ;; esac; } +${inert.map(name => `${name}() { return 0; }`).join('\n')} +prepare_material_preview_release() { printf 'prepare\n'; } +run_material_preview_release_gate() { printf 'probe:%s\n' "$2"; [ "$2" != '${phase}' ]; } +fail_direct_replace_with_restore() { printf 'existing-rollback-once\n'; exit 81; } +wait_for_container_health() { printf '172.18.0.9\n'; } +write_proxy_config() { printf 'traffic-prepared\n'; } +switch_proxy() { printf 'traffic-switched\n'; } +write_current_container() { printf 'current-recorded\n'; } +accept_material_preview_release() { printf 'baseline-promoted\n'; } +transcode_drain_file() { printf '/not-a-real-drain-file\n'; } +task_detail_drain_file() { printf '/not-a-real-upload-file\n'; } +rm() { return 0; } +${direct} +direct_replace_active_container material-blue +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, phase === 'none' ? 0 : phase === 'capture' ? 82 : 81, result.stderr || result.stdout) + assert.equal((result.stdout.match(/existing-rollback-once/g) || []).length, ['candidate', 'post'].includes(phase) ? 1 : 0) + if (phase === 'capture') assert.doesNotMatch(result.stdout, /docker:stop|traffic-switched|baseline-promoted/) + if (phase === 'candidate') assert.doesNotMatch(result.stdout, /traffic-switched|baseline-promoted/) + if (phase === 'post') { assert.match(result.stdout, /traffic-switched/); assert.doesNotMatch(result.stdout, /current-recorded|baseline-promoted/) } + if (phase === 'none') assert.ok(result.stdout.indexOf('probe:post') < result.stdout.indexOf('baseline-promoted')) + } +}) + test('storage deployment gate rejects broken public bucket URLs before authenticating', async () => { const fn = shellFunction('verify_candidate_authenticated_s3') const code = fn.slice(fn.indexOf("node -e '\n") + "node -e '\n".length, fn.lastIndexOf("\n'")) @@ -427,6 +525,33 @@ test('detail beta configuration validates enabled key/base without exposing prov assert.doesNotMatch(source, /-e [^\n]*DETAIL_GENERATOR_API_KEY/) }) +test('normal blue-green releases prepare the feature before draining or creating a candidate', () => { + const source = deploySource() + const main = source.slice(source.indexOf('\nverify_bootstrap_handoff_if_present\n')) + const prepare = main.indexOf('prepare_detail_generator_release || fail ') + assert.ok(prepare > 0, 'normal deployment must read the approved feature configuration') + assert.ok(prepare < main.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do')) + assert.ok(prepare < main.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')) + assert.match(source, /DETAIL_FEATURE_ENABLED=uninitialized/) +}) + +test('container creation refuses an unprepared feature flag before any Docker or filesystem operation', () => { + for (const state of ['', 'uninitialized', 'invalid']) { + const script = `set -eu +DETAIL_FEATURE_ENABLED='${state}' +warn() { printf '%s\\n' "$*" >&2; } +docker() { printf 'UNEXPECTED_DOCKER'; return 99; } +mktemp() { printf 'UNEXPECTED_WRITE'; return 99; } +${shellFunction('create_container')} +create_container material-blue local:fixture 0 +` + const result = spawnSync(bash, ['-s'], { input: script, encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /configuration was not prepared/) + assert.doesNotMatch(result.stdout, /UNEXPECTED_/) + } +}) + test('fixed private storage rejects overlapping uploads before creating directories', () => { const check = shellFunction('detail_private_mount_is_safe') const privateRoot = '/mnt/PoolOne/apps/youyidian-3d/detail-generator-private' diff --git a/scripts/install-desktop-release-admission.py b/scripts/install-desktop-release-admission.py new file mode 100644 index 00000000..5ac48978 --- /dev/null +++ b/scripts/install-desktop-release-admission.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Install only this project's admission hook; never restart application containers.""" +import argparse +import base64 +import fcntl +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import types + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +GUARD = POLICY / 'guard.py' +UNIT = 'youyidian-3d-material-local-proxy.service' +UNIT_FILE = Path('/etc/systemd/system') / UNIT +DROPIN = Path(str(UNIT_FILE) + '.d/30-desktop-admission.conf') +PROXY = BASE / 'local-proxy/youyidian-3d-material' + + +def dropin_text(): + # An independent drop-in survives legacy writers replacing the base unit. + return ('[Service]\n' + f'ExecStartPre=/usr/bin/python3 {GUARD}\n' + 'ExecReload=\n' + f'ExecReload=/usr/bin/python3 {GUARD}\n' + f'ExecReload=/usr/sbin/nginx -p {PROXY}/ -c {PROXY}/nginx.conf -s reload\n') + + +def run(args): + value = subprocess.run(args, capture_output=True, text=True, timeout=25) + if value.returncode != 0: + raise RuntimeError('system_command_failed') + return value.stdout.strip() + + +def atomic_write(path, data, mode): + fd, temp = tempfile.mkstemp(prefix='.admission-install-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apply', action='store_true') + parser.add_argument('--verify-reload', action='store_true') + parser.add_argument('--expect-container', required=True) + parser.add_argument('--expect-image', required=True) + parser.add_argument('--expect-commit', required=True) + parser.add_argument('--entry-sha256', required=True) + args = parser.parse_args() + handles = [] + try: + if os.geteuid() != 0: + raise RuntimeError('root_required') + if 'STAGED_POLICY_BASE64' in globals(): + source = base64.b64decode(STAGED_POLICY_BASE64, validate=True) + else: + source = Path(__file__).with_name('desktop-release-admission.py').read_bytes() + source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + digest = hashlib.sha256(source).hexdigest() + module = types.ModuleType('desktop_admission_install_probe') + module.__file__ = str(GUARD) + exec(compile(source, str(GUARD), 'exec'), module.__dict__) + require = module.require + # Same lock order as the canonical deployment, scoped to this project. + for name in ['youyidian-3d-material-production-deploy.lock', 'youyidian-3d-material-local-proxy-reconcile.lock']: + flags = os.O_WRONLY | os.O_NOFOLLOW + if args.apply: + flags |= os.O_CREAT + fd = os.open('/run/' + name, flags, 0o600) + handles.append(fd) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + snapshot = module.current_snapshot() + require(snapshot['containerId'] == args.expect_container and snapshot['imageId'] == args.expect_image + and snapshot['revision'] == args.expect_commit, 'active_identity_changed') + module.physical(UNIT_FILE, owner=(0, 0)) + require(run(['systemctl', 'show', UNIT, '-p', 'FragmentPath', '--value']) == str(UNIT_FILE), 'unit_identity_changed') + require(run(['systemctl', 'is-active', UNIT]) == 'active', 'proxy_not_active') + before_pid = run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) + require(before_pid.isdecimal() and before_pid != '0', 'proxy_pid_invalid') + if GUARD.exists(): + module.physical(GUARD, owner=(0, 0), mode=0o555) + require(hashlib.sha256(GUARD.read_bytes()).hexdigest() == digest, 'existing_policy_requires_reviewed_upgrade') + for path in [POLICY.parent, POLICY, POLICY / 'receipts', DROPIN.parent]: + require(not path.is_symlink(), 'symlink_rejected') + if path.exists(): + module.physical(path, directory=True, owner=(0, 0)) + if DROPIN.exists(): + module.physical(DROPIN, owner=(0, 0), mode=0o644) + require(DROPIN.read_text() == dropin_text(), 'existing_dropin_differs') + result = {'mode': 'apply' if args.apply else 'plan', 'policySha256': digest, + 'unit': UNIT, 'dropIn': str(DROPIN), 'applicationContainerId': snapshot['containerId'], + 'imageId': snapshot['imageId'], 'revision': snapshot['revision'], 'beforeProxyPid': before_pid} + if not args.apply: + print(json.dumps(result)) + return 0 + for path in [POLICY.parent, POLICY, POLICY / 'receipts']: + created = not path.exists() + path.mkdir(mode=0o700, exist_ok=True) + if created: + # NAS root's primary group need not be gid 0. + os.chown(path, 0, 0) + os.chmod(path, 0o700) + module.physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + module.physical(POLICY / 'receipts', directory=True, owner=(0, 0), mode=0o700) + if not GUARD.exists(): + atomic_write(GUARD, source, 0o555) + module.attest(snapshot, args.entry_sha256) + module.validate_receipt(snapshot) + created_dropin_dir = not DROPIN.parent.exists() + DROPIN.parent.mkdir(mode=0o755, exist_ok=True) + if created_dropin_dir: + os.chown(DROPIN.parent, 0, 0) + os.chmod(DROPIN.parent, 0o755) + if not DROPIN.exists(): + atomic_write(DROPIN, dropin_text().encode(), 0o644) + run(['systemctl', 'daemon-reload']) + reload_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecReload', '--value']) + start_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecStartPre', '--value']) + require(str(GUARD) in reload_commands and reload_commands.index(str(GUARD)) < reload_commands.index('/usr/sbin/nginx'), + 'reload_hook_not_active') + require(str(GUARD) in start_commands, 'start_hook_not_active') + if args.verify_reload: + run(['systemctl', 'reload', UNIT]) + require(run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) == before_pid, 'proxy_process_restarted') + require(module.current_snapshot() == snapshot, 'application_changed_during_install') + result.update({'installed': True, 'reloadVerified': args.verify_reload, 'applicationUnchanged': True, + 'proxyPidUnchanged': True, 'startHookVerified': True, 'reloadHookVerified': True}) + print(json.dumps(result)) + return 0 + except Exception as error: + # Do not emit arbitrary subprocess output, HTTP bodies or env values. + allowed = {'root_required', 'system_command_failed'} + reason = str(error) if type(error).__name__ == 'Rejected' or str(error) in allowed else 'installation_stopped' + print(json.dumps({'installed': False, 'reason': reason}), file=sys.stderr) + return 1 + finally: + for fd in handles: + os.close(fd) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/material-preview-release-gate.mjs b/scripts/material-preview-release-gate.mjs new file mode 100644 index 00000000..124195e4 --- /dev/null +++ b/scripts/material-preview-release-gate.mjs @@ -0,0 +1,309 @@ +// Read-only release inventory and bounded probes. No writes to materials or storage. +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { checkMaterialPreviewHealth, directFetch } from './check-material-preview-health.mjs' +import { collectMaterialPreviewInventory } from './export-material-preview-health-manifest.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +export const POLICY = Object.freeze({ version: 1, concurrency: 4, probes: 768, bytes: 128 * 1024 * 1024, wallMs: 360_000 }) +export const CRITICAL_MATERIAL_IDS = Object.freeze([ + '6e763f52-a21f-4b4e-99fb-634478f68eed', '82f3a6b7-6323-4cf5-a741-d990182f382c', + 'd3a0addb-b4f0-4110-958d-3f897490fa4c', '72a39203-7f61-43b5-b243-73fe19ffde99', + 'a6b97e8a-bd68-483f-b8f8-743e8baef850', '0329debe-054d-4ee7-9411-59f7cb2cdcc1', + 'b9a9947c-ef68-4616-9144-a0d1fd912799', 'ac19c78a-c09f-4ed9-a3c3-12bf9c950a56', + 'ea4cb1d2-fd25-46cb-aeba-93508c0793f8', '6c2675ab-c495-4c63-8d63-24ddee9e6535', + 'c9abdd91-a666-4f36-a475-6e082906a94e', 'd0e929f7-1186-4eac-acef-3af74a9567ee', +]) +export const DELIVERY_FILES = Object.freeze(['lib/url-rewrite.ts', 'lib/proxy-media-security.ts', 'app/api/proxy-media/route.ts', 'lib/s3.ts', 'lib/network-detection.ts']) +export const KNOWN_FRP_FAILURE = Object.freeze({ bytes: 489, bodySha256: '967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023' }) +export const KNOWN_COMPATIBILITY_PROOF = Object.freeze({ materialId: '6e763f52-a21f-4b4e-99fb-634478f68eed', + bodySha256: 'b40687cac0cbd537e209f8caa56c642bf501a658d0c3585140e8b9950937da53', width: 768, height: 1600 }) +export const KNOWN_LEGACY_MEMBERS_SHA256 = '953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c' +export const DELIVERY_CONFIG_KEYS = Object.freeze(['S3_ENDPOINT', 'S3_BUCKET', 'S3_FILE_PATH', 'S3_PUBLIC_DOMAIN', 'S3_REGION', 'SERVER_IP', 'MINIO_API_PORT', 'APP_PUBLIC_URL']) +// bundleReleaseGate replaces this literal with hashes from the exact build checkout. +const PINNED_TARGET_DELIVERY_CODE = null +const sha = value => createHash('sha256').update(value).digest('hex') +const digest = value => sha(JSON.stringify(value)) +const runtimeRequire = createRequire(import.meta.url) +export class GateError extends Error { constructor(code) { super(code); this.code = code } } +const fail = code => { throw new GateError(code) } +const sourceKey = row => `${row.materialId}:${row.sourceSha256}` +const probeKey = row => `${row.materialId}:${row.role}:${row.sourceSha256}` + +async function readKnownLegacyProof(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) { + const policy = JSON.parse(await readFile(path.join(root, 'scripts/fixtures/material-preview-known-legacy-20261023.json'), 'utf8')) + if (policy?.version !== 1 || policy.reviewedAuditSha256 !== '40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d' + || policy.members?.length !== 472 || new Set(policy.members.map(row => row.materialId)).size !== 472 + || digest(policy.members) !== KNOWN_LEGACY_MEMBERS_SHA256 || policy.membersSha256 !== KNOWN_LEGACY_MEMBERS_SHA256) fail('reviewed_legacy_membership_proof_changed') + return policy.members +} +// The exact-checkout bundler inlines this reviewed ID/hash-only proof. +const PINNED_KNOWN_LEGACY_MEMBERS = await readKnownLegacyProof() + +export function normalizeDeliveryConfig(raw) { + if (!raw || Object.keys(raw).sort().join('|') !== [...DELIVERY_CONFIG_KEYS].sort().join('|')) fail('complete_delivery_configuration_required') + const values = Object.fromEntries(DELIVERY_CONFIG_KEYS.map(key => { + if (typeof raw[key] !== 'string' || raw[key].length > 2048 || /[\x00-\x1f\x7f]/.test(raw[key])) fail('invalid_delivery_configuration') + return [key, raw[key].trim()] + })) + for (const key of ['S3_ENDPOINT', 'S3_PUBLIC_DOMAIN', 'APP_PUBLIC_URL']) { + let url + try { url = new URL(values[key]) } catch { fail('invalid_delivery_configuration_url') } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('credential_or_query_in_delivery_configuration') + values[key] = url.href.replace(/\/$/, '') + } + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(values.S3_BUCKET) || values.S3_BUCKET.includes('..')) fail('invalid_delivery_bucket') + if (values.SERVER_IP && !/^[A-Za-z0-9.:\[\]-]+$/.test(values.SERVER_IP)) fail('invalid_delivery_server') + values.MINIO_API_PORT ||= '9000' + if (!/^\d{1,5}$/.test(values.MINIO_API_PORT) || Number(values.MINIO_API_PORT) < 1 || Number(values.MINIO_API_PORT) > 65535) fail('invalid_delivery_port') + values.S3_REGION ||= 'us-east-1' + return values +} + +export function canonicalDeliverySource(source, publicDomain) { + const current = new URL(publicDomain) + if (current.href.replace(/\/$/, '') !== 'https://s3.jiansun.vip/gongxiangpan') fail('untrusted_public_bucket_configuration') + const url = new URL(source) + // Independent historical bucket is deliberately never mapped to the current bucket. + if (url.hostname === LEGACY_DOMAINS[0] && url.pathname.startsWith('/jsxiangche/')) return url.href + if (!url.pathname.startsWith('/gongxiangpan/')) return url.href + url.protocol = current.protocol; url.hostname = current.hostname; url.port = '' + return url.href +} + +export function inventoryIndex(inventory) { + const { audit, manifests } = inventory + if (audit?.inventoryComplete !== true || audit.activeCount !== audit.exportedCount + audit.uncheckableCount + || manifests.reduce((n, batch) => n + batch.materials.length, 0) !== audit.exportedCount) fail('inventory_coverage_incomplete') + const index = [ + ...manifests.flatMap(batch => batch.materials.map(row => ({ materialId: row.materialId, sourceSha256: sha(row.registeredSource), registeredSource: row.registeredSource }))), + ...audit.uncheckable.map(row => ({ materialId: row.materialId, sourceSha256: row.sourceSha256, uncheckable: { classification: row.classification, code: row.code } })), + ].sort((a, b) => a.materialId.localeCompare(b.materialId, 'en')) + if (index.length !== audit.activeCount || new Set(index.map(row => row.materialId)).size !== index.length) fail('inventory_coverage_incomplete') + return index +} + +function validateIndex(index) { + if (!Array.isArray(index) || new Set(index.map(row => row.materialId)).size !== index.length + || index.some(row => typeof row.materialId !== 'string' || !/^[a-f0-9]{64}$/.test(row.sourceSha256) + || (row.registeredSource ? sha(row.registeredSource) !== row.sourceSha256 : !row.uncheckable))) fail('invalid_inventory_index') +} + +export function buildPlan(index, prior, { publicDomain, criticalIds = CRITICAL_MATERIAL_IDS, expandedScope = false, bootstrap = false } = {}) { + validateIndex(index) + if (!bootstrap) validateAccepted(prior) + const previous = new Map((prior?.inventory || []).map(row => [row.materialId, row])) + const required = new Set(criticalIds), selected = [], unverified = [], changedUncheckable = [] + for (const row of index) { + const old = previous.get(row.materialId) + const changed = !old || sourceKey(old) !== sourceKey(row) + if (!row.registeredSource) { + if (!bootstrap && changed) changedUncheckable.push(row.materialId) + unverified.push({ materialId: row.materialId, reason: 'unsupported_or_missing_registered_source' }) + continue + } + const source = new URL(row.registeredSource) + const independentLegacy = source.hostname === LEGACY_DOMAINS[0] && source.pathname.startsWith('/jsxiangche/') + const reasons = [...(independentLegacy ? ['independent_legacy_storage'] : []), ...(required.has(row.materialId) ? ['critical_material'] : []), + ...(!bootstrap && changed ? ['new_or_changed_source'] : []), ...(expandedScope ? ['delivery_dependency_changed'] : [])] + if (!reasons.length) { unverified.push({ materialId: row.materialId, reason: 'unchanged_current_source_outside_bounded_scope' }); continue } + const delivery = canonicalDeliverySource(row.registeredSource, publicDomain) + selected.push({ materialId: row.materialId, registeredSource: row.registeredSource, sourceSha256: row.sourceSha256, reasons, + deliverySource: delivery, independentLegacy }) + } + const present = new Set(selected.map(row => row.materialId)) + if ([...required].some(id => !present.has(id))) fail('critical_material_coverage_missing') + if (changedUncheckable.length) fail('new_or_changed_uncheckable_preview') + const probes = selected.flatMap(row => [ + { materialId: row.materialId, role: 'registered', registeredSource: row.registeredSource, sourceSha256: row.sourceSha256 }, + ...(row.deliverySource === row.registeredSource ? [] : [{ materialId: row.materialId, role: 'canonical', registeredSource: row.deliverySource, sourceSha256: sha(row.deliverySource) }]), + ]) + if (!probes.length || probes.length > POLICY.probes) fail('probe_budget_requires_independent_full_acceptance') + return { selected, probes, unverified, expandedScope, publicDomain, criticalIds: [...criticalIds] } +} + +function failureSignature(result) { + return digest([result.classification, result.code, result.status, result.contentType, result.bytes, result.bodySha256 ?? null]) +} +function validateChecks(plan, checks) { + if (!Array.isArray(checks) || checks.length !== plan.probes.length) fail('probe_coverage_incomplete') + const expected = new Set(plan.probes.map(probeKey)), seen = new Set() + for (const check of checks) { + const key = probeKey(check) + if (!expected.has(key) || seen.has(key) || check.result?.source?.sha256 !== check.sourceSha256 || check.result.attempts !== 1 + || check.result.materialId !== check.materialId || typeof check.result.ok !== 'boolean') fail('probe_identity_or_coverage_mismatch') + if (check.result.ok && (!/^[a-f0-9]{64}$/.test(check.result.bodySha256) || check.result.classification !== 'healthy' + || check.result.status < 200 || check.result.status >= 300 || !Number.isSafeInteger(check.result.decoded?.width) || !Number.isSafeInteger(check.result.decoded?.height) + || check.result.decoded.width < 1 || check.result.decoded.height < 1 || check.result.decoded.width * check.result.decoded.height > 25_000_000)) fail('undecoded_healthy_probe') + seen.add(key) + } +} + +export function validateAccepted(artifact) { + if (artifact?.version !== 1 || artifact.policyVersion !== POLICY.version || artifact.passed !== true + || !['bootstrap', 'post'].includes(artifact.stage) || !/^[a-f0-9]{64}$/.test(artifact.inventorySha256)) fail('reviewed_baseline_required') + validateIndex(artifact.inventory) + if (digest(normalizeDeliveryConfig(artifact.deliveryConfig)) !== artifact.deliveryConfigSha256 + || artifact.knownLegacyMembersSha256 !== KNOWN_LEGACY_MEMBERS_SHA256) fail('baseline_delivery_or_legacy_proof_mismatch') + if (digest(artifact.inventory) !== artifact.inventorySha256 || digest(artifact.plan) !== artifact.planSha256) fail('baseline_digest_mismatch') + validateChecks(artifact.plan, artifact.checks) + if (digest(artifact.checks) !== artifact.checksSha256) fail('baseline_checks_digest_mismatch') +} + +export function compareChecks(plan, checks, baseline, { bootstrap = false, legacyFailureProof = KNOWN_FRP_FAILURE, compatibilityProof = KNOWN_COMPATIBILITY_PROOF, + legacyMembers = PINNED_KNOWN_LEGACY_MEMBERS } = {}) { + validateChecks(plan, checks) + const known = new Map((baseline?.checks || []).map(row => [probeKey(row), row.result])) + const checksMap = new Map(checks.map(row => [probeKey(row), row.result])) + const legacyIdentities = new Set(legacyMembers.map(sourceKey)) + const problems = [], knownFailures = [] + for (const check of checks) { + const row = plan.selected.find(item => item.materialId === check.materialId) + const old = known.get(probeKey(check)), result = check.result + if (['decoder_unavailable', 'invalid_manifest', 'source_rejected'].includes(result.classification)) { problems.push({ materialId: check.materialId, role: check.role, code: 'detector_or_source_validation_failure' }); continue } + if (result.ok) { + if (old?.ok && result.bodySha256 !== old.bodySha256) problems.push({ materialId: check.materialId, role: check.role, code: 'unchanged_url_content_changed' }) + continue + } + // Bootstrap is explicitly installed by a human after reviewing its exact artifact SHA. + // It may document the incident's known FRP misses, never arbitrary new failures. + const legacyFrp = row.independentLegacy && result.classification === 'routing_failure' && result.code === 'frp_default_404' && result.status === 404 + && result.bytes === legacyFailureProof.bytes && result.bodySha256 === legacyFailureProof.bodySha256 + && legacyIdentities.has(sourceKey(row)) + const canonical = checksMap.get(probeKey({ materialId: row.materialId, role: 'canonical', sourceSha256: sha(row.deliverySource) })) + const rawCompatUnavailable = check.role === 'registered' && row.deliverySource !== row.registeredSource && canonical?.ok + && row.materialId === compatibilityProof.materialId && canonical.bodySha256 === compatibilityProof.bodySha256 + && canonical.decoded.width === compatibilityProof.width && canonical.decoded.height === compatibilityProof.height + && ['network_failure', 'timeout'].includes(result.classification) + if (bootstrap ? (legacyFrp || rawCompatUnavailable) : old && !old.ok && failureSignature(result) === failureSignature(old)) { + knownFailures.push({ materialId: check.materialId, role: check.role, classification: result.classification, code: result.code, status: result.status }) + } else problems.push({ materialId: check.materialId, role: check.role, code: 'new_or_changed_failure' }) + } + // Canonical delivery must be healthy except for the explicitly documented independent legacy incident. + for (const row of plan.selected) { + const role = row.deliverySource === row.registeredSource ? 'registered' : 'canonical' + const result = checksMap.get(probeKey({ materialId: row.materialId, role, sourceSha256: sha(row.deliverySource) })) + if (!result?.ok && !row.independentLegacy) problems.push({ materialId: row.materialId, role, code: 'delivery_preview_unavailable' }) + } + return { passed: problems.length === 0, problems, knownFailures, allSelectedHealthy: checks.every(row => row.result.ok) } +} + +export async function runProbes(plan, { fetchImpl = directFetch, decodeImage, now = Date.now } = {}) { + if (!Array.isArray(plan?.probes) || !plan.probes.length || plan.probes.length > POLICY.probes) fail('probe_budget_requires_independent_full_acceptance') + const start = now(), controller = new AbortController() + let bytes = 0, cursor = 0, budgetFailure = null + const timer = setTimeout(() => { budgetFailure = 'release_probe_wall_budget'; controller.abort() }, POLICY.wallMs) + const checks = new Array(plan.probes.length) + const guardedFetch = async (url, options) => { + const response = await fetchImpl(url, { ...options, signal: AbortSignal.any([options.signal, controller.signal]) }) + if (!response.body) return response + const guarded = new Response(response.body.pipeThrough(new TransformStream({ transform(chunk, output) { + bytes += chunk.byteLength + if (bytes > POLICY.bytes) { budgetFailure = 'release_probe_byte_budget'; controller.abort(); throw new GateError(budgetFailure) } + output.enqueue(chunk) + } })), { status: response.status, headers: response.headers }) + Object.defineProperties(guarded, { redirected: { value: response.redirected }, url: { value: response.url } }) + return guarded + } + try { + await Promise.all(Array.from({ length: Math.min(POLICY.concurrency, plan.probes.length) }, async () => { + while (cursor < plan.probes.length && !controller.signal.aborted) { + if (now() - start >= POLICY.wallMs) { budgetFailure = 'release_probe_wall_budget'; controller.abort(); break } + const index = cursor++, probe = plan.probes[index] + const manifest = { version: 1, reviewed: true, materials: [{ materialId: probe.materialId, registeredSource: probe.registeredSource }] } + const report = await checkMaterialPreviewHealth(manifest, { fetchImpl: guardedFetch, ...(decodeImage ? { decodeImage } : {}) }) + checks[index] = { ...probe, result: report.results[0] } + } + })) + } finally { clearTimeout(timer); controller.abort() } + if (budgetFailure) fail(budgetFailure) + validateChecks(plan, checks) + return { checks, budget: { ...POLICY, actualBytes: bytes, elapsedMs: now() - start, requests: checks.length, retries: 0 } } +} + +async function deliveryCode(root) { + return Object.fromEntries(await Promise.all(DELIVERY_FILES.map(async file => [file, sha(await readFile(path.join(root, file)))]))) +} + +export async function runReleaseStage(input, { prismaClient, collectInventory = collectMaterialPreviewInventory, probe = runProbes, + root = '/app', readDeliveryCode = deliveryCode, environment = process.env, targetDeliveryCode = PINNED_TARGET_DELIVERY_CODE, + legacyFailureProof = KNOWN_FRP_FAILURE, compatibilityProof = KNOWN_COMPATIBILITY_PROOF, legacyMembers = PINNED_KNOWN_LEGACY_MEMBERS } = {}) { + if (input?.version !== 1 || !['bootstrap', 'capture', 'candidate', 'post'].includes(input.stage) + || !/^[a-f0-9]{40}$/.test(input.commit)) fail('invalid_release_stage') + const bootstrap = input.stage === 'bootstrap' + if (!bootstrap) validateAccepted(input.accepted) + const owned = !prismaClient + const prisma = prismaClient || new (runtimeRequire('@prisma/client').PrismaClient)({ log: [] }) + let collected + try { collected = await collectInventory(prisma) } finally { if (owned) await prisma.$disconnect() } + const inventory = inventoryIndex(collected), inventorySha256 = digest(inventory) + const code = await readDeliveryCode(root) + if (Object.keys(code).sort().join('|') !== [...DELIVERY_FILES].sort().join('|') || Object.values(code).some(v => !/^[a-f0-9]{64}$/.test(v))) fail('delivery_code_identity_unavailable') + const deliveryConfig = normalizeDeliveryConfig(Object.fromEntries(DELIVERY_CONFIG_KEYS.map(key => [key, environment[key] ?? '']))) + const publicDomain = deliveryConfig.S3_PUBLIC_DOMAIN + const targetConfig = bootstrap ? deliveryConfig : normalizeDeliveryConfig(input.targetDeliveryConfig) + let plan, expected + if (bootstrap || input.stage === 'capture') { + const expandedScope = !bootstrap && (!targetDeliveryCode || digest(code) !== digest(targetDeliveryCode) + || digest(input.accepted.deliveryCode) !== digest(code) || digest(input.accepted.deliveryConfig) !== digest(deliveryConfig) + || digest(targetConfig) !== digest(deliveryConfig)) + plan = buildPlan(inventory, input.accepted, { publicDomain, expandedScope, bootstrap }) + expected = input.accepted + } else { + const before = input.capture + if (before?.stage !== 'capture' || before.passed !== true || before.commit !== input.commit || before.inventorySha256 !== inventorySha256 + || before.metadataAudit?.inventorySha256 !== collected.audit.inventorySha256 + || digest(before.inventory) !== inventorySha256 || digest(before.plan) !== before.planSha256 + || before.acceptedArtifactSha256 !== digest(input.accepted)) fail('inventory_or_capture_drift') + if (!targetDeliveryCode || digest(code) !== digest(targetDeliveryCode) || digest(deliveryConfig) !== digest(before.targetDeliveryConfig) + || digest(targetConfig) !== digest(before.targetDeliveryConfig) || publicDomain !== before.plan.publicDomain) fail('candidate_delivery_identity_mismatch') + validateChecks(before.plan, before.checks) + if (digest(before.checks) !== before.checksSha256) fail('capture_checks_digest_mismatch') + plan = before.plan + expected = before + if (input.stage === 'post') { + if (input.candidate?.stage !== 'candidate' || input.candidate.passed !== true || input.candidate.planSha256 !== before.planSha256 + || input.candidate.commit !== input.commit || input.candidate.acceptedArtifactSha256 !== digest(input.accepted) + || digest(input.candidate.plan) !== before.planSha256 || digest(input.candidate.inventory) !== inventorySha256 + || input.candidate.metadataAudit?.inventorySha256 !== collected.audit.inventorySha256 + || input.candidate.inventorySha256 !== inventorySha256 || digest(input.candidate.checks) !== input.candidate.checksSha256) fail('candidate_receipt_required') + validateChecks(plan, input.candidate.checks) + expected = input.candidate + } + } + const { checks, budget } = await probe(plan) + const comparison = compareChecks(plan, checks, expected, { bootstrap, legacyFailureProof, compatibilityProof, legacyMembers }) + const artifact = { version: 1, policyVersion: POLICY.version, readOnly: true, stage: input.stage, commit: input.commit, + capturedAt: new Date().toISOString(), ...comparison, inventory, inventorySha256, metadataAudit: collected.audit, + plan, planSha256: digest(plan), checks, checksSha256: digest(checks), budget, deliveryCode: code, + deliveryConfig, deliveryConfigSha256: digest(deliveryConfig), targetDeliveryConfig: targetConfig, + knownLegacyMembersSha256: KNOWN_LEGACY_MEMBERS_SHA256, + ...(bootstrap ? { bootstrapRequiresExplicitShaReview: true } : { acceptedArtifactSha256: digest(input.accepted) }), + scopeNotice: 'Only selected previews were fetched; all remaining metadata rows are explicitly unverified. Known incident failures are not healthy.' } + return artifact +} + +/** Package only exact-checkout code, with dependency hashes; old images need no new files. */ +export async function bundleReleaseGate(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), { runtimeBase = 'file:///app/package.json' } = {}) { + const moduleUrl = source => 'data:text/javascript;base64,' + Buffer.from(source).toString('base64') + const adapt = source => source.replaceAll('import.meta.url', JSON.stringify(runtimeBase)) + const registry = moduleUrl(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) + const checker = moduleUrl(adapt(await readFile(path.join(root, 'scripts/check-material-preview-health.mjs'), 'utf8')).replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))) + const exporter = moduleUrl(adapt(await readFile(path.join(root, 'scripts/export-material-preview-health-manifest.mjs'), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)).replace("'./check-material-preview-health.mjs'", JSON.stringify(checker))) + const gate = adapt((await readFile(path.join(root, 'scripts/material-preview-release-gate.mjs'), 'utf8')) + .replace('const PINNED_KNOWN_LEGACY_MEMBERS = await readKnownLegacyProof()', `const PINNED_KNOWN_LEGACY_MEMBERS = ${JSON.stringify(await readKnownLegacyProof(root))}`)) + .replace('const PINNED_TARGET_DELIVERY_CODE = null', `const PINNED_TARGET_DELIVERY_CODE = ${JSON.stringify(await deliveryCode(root))}`) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)) + .replace("'./check-material-preview-health.mjs'", JSON.stringify(checker)) + .replace("'./export-material-preview-health-manifest.mjs'", JSON.stringify(exporter)) + return moduleUrl(gate) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length === 3 && process.argv[2] === 'bundle') process.stdout.write(await bundleReleaseGate()) + else { console.error('Usage: node scripts/material-preview-release-gate.mjs bundle'); process.exitCode = 1 } +} diff --git a/scripts/material-preview-release-gate.test.mjs b/scripts/material-preview-release-gate.test.mjs new file mode 100644 index 00000000..195e4478 --- /dev/null +++ b/scripts/material-preview-release-gate.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import test from 'node:test' +import { bundleReleaseGate, buildPlan, canonicalDeliverySource, compareChecks, CRITICAL_MATERIAL_IDS, DELIVERY_FILES, KNOWN_LEGACY_MEMBERS_SHA256, normalizeDeliveryConfig, POLICY, runProbes, runReleaseStage, validateAccepted } from './material-preview-release-gate.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const sharp = createRequire(import.meta.url)('sharp') +const PNG = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#4d5e6f' } }).png().toBuffer() +const hash = value => createHash('sha256').update(value).digest('hex') +const digest = value => hash(JSON.stringify(value)) +const current = id => `https://s3.jiansun.vip/gongxiangpan/${id}.png` +const independent = id => `https://${LEGACY_DOMAINS[0]}/jsxiangche/${id}.png` +const compatible = id => `https://${LEGACY_DOMAINS[1]}/gongxiangpan/${id}.png` +const FRP = '

The server is powered by frp.

Faithfully yours, frp.

' +const row = (materialId, registeredSource = current(materialId)) => ({ materialId, registeredSource, sourceSha256: hash(registeredSource) }) +const code = Object.fromEntries(DELIVERY_FILES.map(file => [file, 'c'.repeat(64)])) +const config = { S3_ENDPOINT: 'http://192.168.58.20:9000', S3_BUCKET: 'gongxiangpan', S3_FILE_PATH: '由壹点AI/', + S3_PUBLIC_DOMAIN: 'https://s3.jiansun.vip/gongxiangpan', S3_REGION: 'us-east-1', SERVER_IP: '192.168.58.20', MINIO_API_PORT: '9000', APP_PUBLIC_URL: 'https://sc.jiansun.vip' } +const initial = () => [...CRITICAL_MATERIAL_IDS.map((id, i) => row(id, i ? current(id) : compatible(id))), row('legacy', independent('legacy')), row('untested-current'), + { materialId: 'private-source', sourceSha256: hash('uploads/materials/private'), uncheckable: { classification: 'source_rejected', code: 'invalid_url' } }] + +function database(rows) { + const events = [] + const normalized = rows.map(r => ({ id: r.materialId, previewUrl: r.registeredSource || 'uploads/materials/private', materialType: '材质' })).sort((a, b) => a.id.localeCompare(b.id, 'en')) + return { events, prisma: { async $transaction(fn, options) { + events.push(options) + return fn({ async $executeRawUnsafe(sql) { events.push(sql) }, material: { + async count(query) { events.push(query); return normalized.length }, async findMany(query) { + events.push(query) + const start = query.cursor ? normalized.findIndex(r => r.id === query.cursor.id) + 1 : 0 + return normalized.slice(start, start + query.take) + }, + } }) + } } } +} + +const fixtureFetch = async url => { + const parsed = new URL(url) + if (parsed.hostname === LEGACY_DOMAINS[0]) return new Response(FRP, { status: 404, headers: { 'Content-Type': 'text/html' } }) + if (parsed.hostname === LEGACY_DOMAINS[1]) throw new Error('old same-bucket route unavailable') + return new Response(PNG, { status: 200, headers: { 'Content-Type': 'image/png' } }) +} + +async function stage(stageName, rows, { accepted, capture, candidate, probeFetch = fixtureFetch, actualCode = code, targetCode = code, ...input } = {}) { + const db = database(rows) + const report = await runReleaseStage({ version: 1, stage: stageName, commit: 'b'.repeat(40), accepted, capture, candidate, + targetDeliveryConfig: config, ...input }, { + prismaClient: db.prisma, readDeliveryCode: async () => actualCode, targetDeliveryCode: targetCode, + environment: config, probe: plan => runProbes(plan, { fetchImpl: probeFetch }), + // Synthetic offline fixture proofs; the production stdin runner cannot supply + // these dependency injections and uses the fixed incident/original SHA values. + legacyFailureProof: { bytes: Buffer.byteLength(FRP), bodySha256: hash(FRP) }, + compatibilityProof: { materialId: CRITICAL_MATERIAL_IDS[0], bodySha256: hash(PNG), width: 3, height: 2 }, + legacyMembers: [row('legacy', independent('legacy'))], + }) + assert.equal(db.events[1], 'SET TRANSACTION READ ONLY') + assert.deepEqual(db.events[0], { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }) + return report +} + +test('bootstrap records all metadata, exact 12 materials and all independent legacy rows without inventing health for omitted sources', async () => { + const report = await stage('bootstrap', initial()) + assert.equal(report.passed, true) + assert.equal(report.allSelectedHealthy, false) + assert.equal(report.bootstrapRequiresExplicitShaReview, true) + assert.equal(report.inventory.length, 15) + assert.equal(report.plan.selected.length, 13) + assert.equal(report.checks.length, 14) + assert.equal(report.knownFailures.length, 2) + assert.equal(report.plan.unverified.length, 2) + assert.equal(report.plan.unverified.find(r => r.materialId === 'private-source').reason, 'unsupported_or_missing_registered_source') + assert.equal(report.budget.retries, 0) + validateAccepted(report) + assert.equal(report.checks.find(r => r.materialId === CRITICAL_MATERIAL_IDS[0] && r.role === 'registered').result.ok, false) + assert.equal(report.checks.find(r => r.materialId === CRITICAL_MATERIAL_IDS[0] && r.role === 'canonical').result.bodySha256, hash(PNG)) +}) + +test('fixed same plan is checked before and after candidate with no metadata or content writes', async () => { + const accepted = await stage('bootstrap', initial()) + const rows = [...initial(), row('new-current')] + const capture = await stage('capture', rows, { accepted }) + const candidate = await stage('candidate', rows, { accepted, capture }) + const post = await stage('post', rows, { accepted, capture, candidate }) + assert.equal(capture.plan.selected.some(r => r.materialId === 'new-current' && r.reasons.includes('new_or_changed_source')), true) + assert.equal(post.passed, true) + assert.equal(post.planSha256, capture.planSha256) + assert.equal(candidate.planSha256, capture.planSha256) + assert.equal(post.inventorySha256, capture.inventorySha256) + validateAccepted(post) +}) + +test('missing or forged first baseline stops capture before image GET', async () => { + let calls = 0 + await assert.rejects(stage('capture', initial(), { probeFetch: async () => { calls++; return fixtureFetch(current('x')) } }), /reviewed_baseline_required/) + const accepted = await stage('bootstrap', initial()) + accepted.inventory.pop() + await assert.rejects(stage('capture', initial(), { accepted }), /baseline_digest_mismatch/) + assert.equal(calls, 0) +}) + +test('new failure or changed failure type blocks candidate; existing FRP incident remains explicit', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + const bad = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === current(CRITICAL_MATERIAL_IDS[1]) + ? new Response('failed', { status: 200, headers: { 'Content-Type': 'text/html' } }) : fixtureFetch(url) }) + assert.equal(bad.passed, false) + assert.ok(bad.problems.some(r => r.code === 'new_or_changed_failure')) + assert.ok(bad.knownFailures.some(r => r.materialId === 'legacy' && r.code === 'frp_default_404')) + const legacyChange = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === independent('legacy') + ? new Response('NoSuchKey', { status: 404, headers: { 'Content-Type': 'application/xml' } }) : fixtureFetch(url) }) + assert.equal(legacyChange.passed, false) +}) + +test('a newly registered legacy failure cannot join the known baseline automatically', async () => { + const accepted = await stage('bootstrap', initial()) + const capture = await stage('capture', [...initial(), row('new-legacy', independent('new-legacy'))], { accepted }) + assert.equal(capture.passed, false) + assert.ok(capture.problems.some(r => r.materialId === 'new-legacy' && r.code === 'new_or_changed_failure')) +}) + +test('bootstrap only permits the exact documented FRP body and original canonical compatibility proof', async () => { + const wrongLegacy = await stage('bootstrap', initial(), { probeFetch: async url => url === independent('legacy') + ? new Response(FRP + 'changed', { status: 404, headers: { 'Content-Type': 'text/html' } }) : fixtureFetch(url) }) + assert.equal(wrongLegacy.passed, false) + const wrongCanonical = await stage('bootstrap', initial(), { probeFetch: async url => { + if (url !== current(CRITICAL_MATERIAL_IDS[0])) return fixtureFetch(url) + const different = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#112233' } }).png().toBuffer() + return new Response(different, { headers: { 'Content-Type': 'image/png' } }) + } }) + assert.equal(wrongCanonical.passed, false) +}) + +test('bootstrap rejects an extra historical row, a replacement ID at equal count, and a changed historical source', async () => { + for (const rows of [ + [...initial(), row('unreviewed-old', independent('unreviewed-old'))], + initial().map(r => r.materialId === 'legacy' ? row('replacement-old', independent('legacy')) : r), + initial().map(r => r.materialId === 'legacy' ? row('legacy', independent('different-source')) : r), + ]) { + const bootstrap = await stage('bootstrap', rows) + assert.equal(bootstrap.passed, false) + assert.ok(bootstrap.problems.some(r => r.code === 'new_or_changed_failure')) + } +}) + +test('healthy same URL with different bytes requires review rather than silently rebaselining', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + const variant = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#ffffff' } }).png().toBuffer() + const candidate = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === current(CRITICAL_MATERIAL_IDS[1]) + ? new Response(variant, { headers: { 'Content-Type': 'image/png' } }) : fixtureFetch(url) }) + assert.equal(candidate.passed, false) + assert.ok(candidate.problems.some(r => r.code === 'unchanged_url_content_changed')) +}) + +test('inventory drift, missing coverage, missing candidate, and detector failure all fail closed', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + await assert.rejects(stage('candidate', [...initial(), row('concurrent-upload')], { accepted, capture }), /inventory_or_capture_drift/) + await assert.rejects(stage('post', initial(), { accepted, capture }), /candidate_receipt_required/) + const broken = structuredClone(capture); broken.checks.pop(); broken.checksSha256 = digest(broken.checks) + await assert.rejects(stage('candidate', initial(), { accepted, capture: broken }), /probe_coverage_incomplete/) + const faulty = structuredClone(capture.checks) + faulty[0].result.ok = false; faulty[0].result.classification = 'decoder_unavailable'; faulty[0].result.code = 'image_decoder_unavailable' + assert.equal(compareChecks(capture.plan, faulty, capture).passed, false) +}) + +test('new unsupported source, absent key material and changes beyond budget cannot be dropped', async () => { + const accepted = await stage('bootstrap', initial()) + await assert.rejects(stage('capture', [...initial(), { materialId: 'unsupported-new', sourceSha256: hash('x'), uncheckable: { code: 'invalid_url' } }], { accepted }), /new_or_changed_uncheckable_preview/) + assert.throws(() => buildPlan(initial().slice(1), accepted, { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }), /critical_material_coverage_missing/) + assert.throws(() => buildPlan([...initial(), ...Array.from({ length: POLICY.probes }, (_, i) => row(`new-${i}`))], accepted, + { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }), /probe_budget_requires_independent_full_acceptance/) +}) + +test('delivery source or storage code changes expand scope and cannot skip large inventory', async () => { + const accepted = await stage('bootstrap', initial()) + const changed = { ...code, [DELIVERY_FILES[0]]: 'd'.repeat(64) } + const capture = await stage('capture', initial(), { accepted, targetCode: changed }) + assert.equal(capture.plan.expandedScope, true) + assert.ok(capture.plan.selected.some(r => r.materialId === 'untested-current')) + await assert.rejects(stage('candidate', initial(), { accepted, capture, targetCode: changed }), /candidate_delivery_identity_mismatch/) + const many = [...initial(), ...Array.from({ length: POLICY.probes }, (_, i) => row(`unchanged-${i}`))] + const largeBase = await stage('bootstrap', many) + await assert.rejects(stage('capture', many, { accepted: largeBase, targetCode: changed }), /probe_budget_requires_independent_full_acceptance/) + const outOfBand = await stage('capture', initial(), { accepted, actualCode: changed, targetCode: changed }) + assert.equal(outOfBand.plan.expandedScope, true, 'a change since the accepted code baseline cannot disappear when target already equals current') +}) + +test('nonsecret delivery configuration changes expand scope and inconsistent candidate settings stop before GET', async () => { + const accepted = await stage('bootstrap', initial()) + for (const [key, value] of [['S3_ENDPOINT', 'http://192.168.58.21:9000'], ['S3_BUCKET', 'other-bucket'], ['S3_FILE_PATH', 'other/'], + ['SERVER_IP', '192.168.58.21'], ['MINIO_API_PORT', '9001'], ['S3_REGION', 'other-region']]) { + const targetDeliveryConfig = { ...config, [key]: value } + const capture = await stage('capture', initial(), { accepted, targetDeliveryConfig }) + assert.equal(capture.plan.expandedScope, true, key) + await assert.rejects(stage('candidate', initial(), { accepted, capture, targetDeliveryConfig }), /candidate_delivery_identity_mismatch/) + } + await assert.rejects(stage('capture', initial(), { accepted, targetDeliveryConfig: undefined }), /complete_delivery_configuration_required/) + assert.throws(() => normalizeDeliveryConfig({ ...config, S3_ENDPOINT: 'http://user:secret@192.168.58.20:9000' }), /credential_or_query_in_delivery_configuration/) + assert.throws(() => normalizeDeliveryConfig({ ...config, S3_ACCESS_KEY_ID: 'must-not-be-logged' }), /complete_delivery_configuration_required/) +}) + +test('versioned historical membership proof exactly matches the independently reviewed 472 IDs and normalized source digests', async () => { + const { readFile } = await import('node:fs/promises') + const policy = JSON.parse(await readFile(new URL('./fixtures/material-preview-known-legacy-20261023.json', import.meta.url), 'utf8')) + assert.equal(policy.members.length, 472) + assert.equal(digest(policy.members), KNOWN_LEGACY_MEMBERS_SHA256) + assert.deepEqual(Object.keys(policy.members[0]).sort(), ['materialId', 'sourceSha256']) + assert.equal(JSON.stringify(policy).includes('https://'), false) +}) + +test('canonical mapping preserves bucket and key and never rewrites independent old storage', () => { + assert.equal(canonicalDeliverySource(independent('original'), 'https://s3.jiansun.vip/gongxiangpan'), independent('original')) + assert.equal(canonicalDeliverySource(compatible('%E4%B8%AD%E6%96%87'), 'https://s3.jiansun.vip/gongxiangpan'), current('%E4%B8%AD%E6%96%87')) + assert.throws(() => canonicalDeliverySource(compatible('x'), 'https://elsewhere.invalid/gongxiangpan'), /untrusted_public_bucket_configuration/) +}) + +test('read-only probe queue is bounded to four requests and one attempt with a fixed wall budget', async () => { + const accepted = await stage('bootstrap', initial()), plan = buildPlan(initial(), accepted, { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }) + let active = 0, peak = 0, calls = 0 + await runProbes(plan, { fetchImpl: async (url, options) => { + calls++; peak = Math.max(peak, ++active) + assert.equal(options.method, 'GET'); assert.equal(options.redirect, 'manual'); assert.equal(options.cache, 'no-store') + await new Promise(resolve => setTimeout(resolve, 2)); active-- + return fixtureFetch(url) + } }) + assert.equal(calls, plan.probes.length); assert.equal(peak, POLICY.concurrency) + let time = 0 + await assert.rejects(runProbes(plan, { now: () => (time += POLICY.wallMs), fetchImpl: () => { throw Error('must not fetch') } }), /release_probe_wall_budget/) +}) + +test('exact-checkout bundle loads without an old image containing new scripts', async () => { + const bundle = await bundleReleaseGate(undefined, { runtimeBase: new URL('../package.json', import.meta.url).href }) + assert.ok(bundle.length < 524288) + const gate = await import(bundle) + assert.equal(typeof gate.runReleaseStage, 'function') + assert.deepEqual(gate.POLICY, POLICY) +})