merge: reconcile verified live salary runtime and material image fixes
Reconstruct the deployed 72674fd runtime from its verified 7e230ef full source and four material overrides. Retain the deployed desktop and preview release gates. Production source hashes and schema were checked before reconstruction.
This commit is contained in:
commit
080e4f82a1
8
app/api/external/download/route.ts
vendored
8
app/api/external/download/route.ts
vendored
@ -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) {
|
||||
|
||||
5
app/api/external/product-assets/route.ts
vendored
5
app/api/external/product-assets/route.ts
vendored
@ -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 || '';
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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('素材尚未完成上传,暂不能编辑图片')
|
||||
}
|
||||
|
||||
|
||||
@ -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('素材尚未完成上传,暂不能修改图片')
|
||||
}
|
||||
|
||||
|
||||
291
scripts/check-material-preview-health.mjs
Normal file
291
scripts/check-material-preview-health.mjs
Normal file
@ -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 <reviewed.json>
|
||||
// {"version":1,"reviewed":true,"materials":[{"materialId":"<id>","registeredSource":"<registered object URL>"}]}
|
||||
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' || /^(?:<!doctype\s+html|<html|<head|<body|<center)\b/i.test(prefix)
|
||||
const frpMarker = /(?:powered\s+by\s+frp\b|faithfully\s+yours,\s*frp\b|<center>\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 && /<Code>\s*NoSuchKey\s*<\/Code>/i.test(prefix)) return failure('missing_object', 's3_no_such_key')
|
||||
if (status === 404 && /<Code>\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
|
||||
})
|
||||
}
|
||||
199
scripts/check-material-preview-health.test.mjs
Normal file
199
scripts/check-material-preview-health.test.mjs
Normal file
@ -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 = '<html>Faithfully yours, frp.</html>'
|
||||
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('<html><center><h1>404 Not Found</h1></center><hr><center>frp</center></html>', '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('<Error><Code>NoSuchKey</Code></Error>', '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 = '<!DOCTYPE html><html><head><title>Not Found</title></head><body><h1>Not Found</h1><p>powered by frp</p><p>Faithfully yours, frp</p></body></html>'
|
||||
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('<!DOCTYPE html><html>wrong gateway</html>', 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')
|
||||
}
|
||||
})
|
||||
240
scripts/desktop-release-admission.py
Normal file
240
scripts/desktop-release-admission.py
Normal file
@ -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())
|
||||
178
scripts/desktop-release-admission.test.py
Normal file
178
scripts/desktop-release-admission.test.py
Normal file
@ -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=[]), '<installer>', '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()
|
||||
141
scripts/export-material-preview-health-manifest.mjs
Normal file
141
scripts/export-material-preview-health-manifest.mjs
Normal file
@ -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 : '<unknown>'
|
||||
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
|
||||
})
|
||||
}
|
||||
196
scripts/export-material-preview-health-manifest.test.mjs
Normal file
196
scripts/export-material-preview-health-manifest.test.mjs
Normal file
@ -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 } })
|
||||
})
|
||||
1896
scripts/fixtures/material-preview-known-legacy-20261023.json
Normal file
1896
scripts/fixtures/material-preview-known-legacy-20261023.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
||||
@ -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" \
|
||||
|
||||
@ -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'
|
||||
|
||||
156
scripts/install-desktop-release-admission.py
Normal file
156
scripts/install-desktop-release-admission.py
Normal file
@ -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())
|
||||
309
scripts/material-preview-release-gate.mjs
Normal file
309
scripts/material-preview-release-gate.mjs
Normal file
@ -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 }
|
||||
}
|
||||
236
scripts/material-preview-release-gate.test.mjs
Normal file
236
scripts/material-preview-release-gate.test.mjs
Normal file
@ -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 = '<html><p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p><p>Faithfully yours, frp.</p></html>'
|
||||
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('<html>failed</html>', { 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('<Error><Code>NoSuchKey</Code></Error>', { 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)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user