import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path'; import type { AssetManifestArtifact } from '../artifacts.js'; import type { VideoProjectWorkspace } from '../workspace.js'; import { isStockProviderId } from './registry.js'; import type { StockAssetProvenance, StockImportReceipt } from './types.js'; export interface StockRightsIssue { assetId: string; message: string; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function nonEmpty(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } function sha256(value: string | Buffer): string { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } function pathWithin(base: string, candidate: string): boolean { const rel = relative(resolve(base), resolve(candidate)); return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)); } function sameStringArray(left: unknown, right: unknown): boolean { return Array.isArray(left) && Array.isArray(right) && JSON.stringify(left) === JSON.stringify(right); } export function collectStockRightsIssues(manifest: unknown): StockRightsIssue[] { if (!isRecord(manifest) || !Array.isArray(manifest.assets)) return []; const issues: StockRightsIssue[] = []; for (const [index, asset] of manifest.assets.entries()) { if (!isRecord(asset)) continue; const assetId = nonEmpty(asset.id) ? asset.id : `asset-${index}`; const sourceProvider = asset.sourceProvider ?? (isRecord(asset.provenance) ? asset.provenance.sourceProvider : undefined); if (!isStockProviderId(sourceProvider)) continue; const provenance = isRecord(asset.provenance) ? asset.provenance as unknown as Partial : null; if (!provenance) { issues.push({ assetId, message: `${assetId}: stock asset is missing provenance.` }); continue; } if (!nonEmpty(provenance.sourceAssetId)) issues.push({ assetId, message: `${assetId}: stock provenance missing sourceAssetId.` }); if (!nonEmpty(provenance.sourcePageUrl)) issues.push({ assetId, message: `${assetId}: stock provenance missing sourcePageUrl.` }); if (!isRecord(provenance.creator) || !nonEmpty(provenance.creator.name)) issues.push({ assetId, message: `${assetId}: stock provenance missing creator attribution.` }); if (!isRecord(provenance.license) || !nonEmpty(provenance.license.name) || !nonEmpty(provenance.license.url)) { issues.push({ assetId, message: `${assetId}: stock provenance missing license name/url.` }); } if (!Array.isArray(provenance.usageScope) || provenance.usageScope.length === 0) issues.push({ assetId, message: `${assetId}: stock provenance missing usageScope.` }); if (!isRecord(provenance.receipt) || !nonEmpty(provenance.receipt.id) || !nonEmpty(provenance.receipt.path) || !nonEmpty(provenance.receipt.sha256)) { issues.push({ assetId, message: `${assetId}: stock provenance missing immutable receipt reference.` }); } if (!nonEmpty(provenance.contentHash)) issues.push({ assetId, message: `${assetId}: stock provenance missing contentHash.` }); } return issues; } export function stockRightsWarnings(manifest: AssetManifestArtifact): string[] { return collectStockRightsIssues(manifest).map((issue) => `stock-rights-incomplete: ${issue.message}`); } export async function collectStockReceiptIntegrityIssues( workspace: VideoProjectWorkspace, manifest: unknown, ): Promise { if (!isRecord(manifest) || !Array.isArray(manifest.assets)) { return [{ assetId: 'asset-manifest', message: 'asset-manifest is malformed, so stock rights cannot be verified.' }]; } const issues = collectStockRightsIssues(manifest); for (const [index, assetValue] of manifest.assets.entries()) { if (!isRecord(assetValue)) continue; const assetId = nonEmpty(assetValue.id) ? assetValue.id : `asset-${index}`; const provenance = isRecord(assetValue.provenance) ? assetValue.provenance : null; const provider = assetValue.sourceProvider ?? provenance?.sourceProvider; if (!isStockProviderId(provider) || !provenance) continue; const receiptRef = isRecord(provenance.receipt) ? provenance.receipt : null; if (!receiptRef || !nonEmpty(receiptRef.id) || !nonEmpty(receiptRef.path) || !nonEmpty(receiptRef.sha256)) continue; const expectedReceiptPath = `artifacts/stock-imports/${receiptRef.id}.json`; const receiptPath = resolve(workspace.projectDir, receiptRef.path); const receiptRoot = resolve(workspace.artifactsDir, 'stock-imports'); if (receiptRef.path !== expectedReceiptPath || !pathWithin(receiptRoot, receiptPath)) { issues.push({ assetId, message: `${assetId}: stock receipt path is outside the immutable receipt store.` }); continue; } if (!existsSync(receiptPath)) { issues.push({ assetId, message: `${assetId}: stock receipt file is missing.` }); continue; } const receiptRaw = await readFile(receiptPath, 'utf-8'); if (sha256(receiptRaw) !== receiptRef.sha256) { issues.push({ assetId, message: `${assetId}: stock receipt checksum does not match the manifest reference.` }); continue; } let receipt: StockImportReceipt; try { receipt = JSON.parse(receiptRaw) as StockImportReceipt; } catch { issues.push({ assetId, message: `${assetId}: stock receipt is malformed JSON.` }); continue; } const identityMatches = receipt.receiptId === receiptRef.id && receipt.projectSlug === workspace.slug && receipt.provider === provider && receipt.providerAssetId === provenance.sourceAssetId && receipt.providerRenditionId === provenance.sourceRenditionId && receipt.sourcePageUrl === provenance.sourcePageUrl && receipt.contentHash === provenance.contentHash && receipt.localAssetPath === assetValue.path && receipt.license?.name === (isRecord(provenance.license) ? provenance.license.name : undefined) && receipt.license?.url === (isRecord(provenance.license) ? provenance.license.url : undefined) && sameStringArray(receipt.usageScope, provenance.usageScope) && sameStringArray(receipt.termsRestrictions, provenance.termsRestrictions); if (!identityMatches) { issues.push({ assetId, message: `${assetId}: stock receipt content does not match manifest provenance.` }); continue; } if (!nonEmpty(assetValue.path)) { issues.push({ assetId, message: `${assetId}: stock asset path is missing.` }); continue; } const assetPath = resolve(workspace.projectDir, assetValue.path); const stockAssetRoot = resolve(workspace.projectDir, 'assets', 'stock', provider); if (!pathWithin(stockAssetRoot, assetPath) || !existsSync(assetPath)) { issues.push({ assetId, message: `${assetId}: stock asset file is missing or outside the provider asset store.` }); continue; } const assetBytes = await readFile(assetPath); if (sha256(assetBytes) !== receipt.contentHash) { issues.push({ assetId, message: `${assetId}: stock asset bytes do not match the immutable receipt.` }); } } return issues; }