{"record":{"id":"6a7f6156eea5e830","repo":"paperclipai/paperclip","slug":"asset-filename-must-match-its-sha-256-digest","errorCode":null,"errorMessage":"Asset filename must match its SHA-256 digest","messagePattern":"Asset filename must match its SHA-256 digest","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"scripts/publish-announcements.ts","lineNumber":61,"sourceCode":"  const source = path.resolve(sourceDirectory);\n  if (!(await lstat(source)).isDirectory()) throw new Error(\"Source must be a real directory\");\n  const manifestPath = path.join(source, \"current.json\");\n  const stat = await lstat(manifestPath);\n  if (!stat.isFile() || stat.size > ANNOUNCEMENT_MANIFEST_MAX_BYTES) throw new Error(\"Invalid or oversized current.json\");\n  const manifest = announcementManifestSchema.parse(JSON.parse(await readFile(manifestPath, \"utf8\")));\n  const files: Array<{ file: string; key: string; contentType: string; cacheControl: string }> = [];\n  for (const kind of [\"image\", \"animation\"] as const) {\n    const asset = manifest.announcement?.[kind];\n    if (!asset) continue;\n    if (!(await lstat(path.join(source, \"assets\"))).isDirectory()) throw new Error(\"Assets must be a real directory\");\n    const assetPath = asset.path;\n    const file = path.join(source, assetPath);\n    const assetStat = await lstat(file);\n    const maximum = kind === \"animation\" ? ANNOUNCEMENT_ANIMATION_MAX_BYTES : ANNOUNCEMENT_IMAGE_MAX_BYTES;\n    if (!assetStat.isFile() || assetStat.size > maximum) throw new Error(`Invalid or oversized ${kind}`);\n    const bytes = await readFile(file);\n    const digest = createHash(\"sha256\").update(bytes).digest(\"hex\");\n    if (!assetPath.startsWith(`assets/${digest}.`)) throw new Error(\"Asset filename must match its SHA-256 digest\");\n    if (kind === \"animation\") validateAnnouncementAnimation(bytes);\n    files.push({ file, key: `${prefix}/${assetPath}`, contentType: kind === \"animation\" ? \"text/html\" : assetPath.endsWith(\".png\") ? \"image/png\" : assetPath.endsWith(\".jpg\") ? \"image/jpeg\" : \"image/webp\", cacheControl: \"public,max-age=31536000,immutable\" });\n  }\n  files.push({ file: manifestPath, key: `${prefix}/current.json`, contentType: \"application/json\", cacheControl: \"public,max-age=300\" });\n  return { manifest, files };\n}\n\nexport function announcementUploadArgs(bucket: string, file: Awaited<ReturnType<typeof prepareAnnouncementPublish>>[\"files\"][number]) {\n  return [\"s3api\", \"put-object\", \"--bucket\", bucket, \"--key\", file.key, \"--body\", file.file,\n    \"--content-type\", file.contentType, \"--cache-control\", file.cacheControl];\n}\n\nasync function main() {\n  const { sourceDirectory, staging, publish } = parseAnnouncementPublishArgs(process.argv.slice(2));\n  const hostPrefix = process.env.PAPERCLIP_PAGE_DEFAULT_PREFIX;\n  const prepared = await prepareAnnouncementPublish(sourceDirectory, staging, hostPrefix);\n  const bucket = process.env.PAPERCLIP_PAGE_BUCKET;\n  const baseUrl = process.env.PAPERCLIP_PAGE_BASE_URL?.replace(/\\/+$/, \"\") ?? \"https://pages.paperclip.ing\";","sourceCodeStart":43,"sourceCodeEnd":79,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/scripts/publish-announcements.ts#L43-L79","documentation":"The publish script enforces content-addressed naming: each asset filename must start with 'assets/<sha256-of-file-content>.' so the immutable CDN cache never serves stale bytes under a reused name. This error means the file's actual SHA-256 digest does not match the digest embedded in the filename declared in the manifest.","triggerScenarios":"Editing or re-encoding an asset after computing its digest filename; renaming a file to another asset's digest; copying an asset and its manifest entry out of sync; generating the manifest before the final asset bytes were written.","commonSituations":"Re-running an image optimizer in place after current.json was generated; a build step that regenerates assets non-deterministically; hand-editing the manifest path; branching where the manifest and asset diverged.","solutions":["Recompute the SHA-256 of the asset file and rename it to assets/<new-digest>.<ext>","Regenerate current.json so manifest.announcement[kind].path references the new digest filename","Re-run your manifest-generation step after any asset change instead of hand-editing paths","Verify with: sha256sum assets/<file> and compare to the filename"],"exampleFix":"// before\nassets/old-digest.png  (bytes changed, name stale)\n// after\nd=$(sha256sum assets/image.png | cut -d' ' -f1) && mv assets/image.png \"assets/$d.png\"","handlingStrategy":"validation","validationCode":"import { createHash } from \"node:crypto\";\nconst digest = createHash(\"sha256\").update(await readFile(file)).digest(\"hex\");\nif (!assetPath.startsWith(`assets/${digest}.`)) throw new Error(\"rename asset to assets/<sha256>.<ext>\");","typeGuard":null,"tryCatchPattern":"try { await prepareAnnouncementPublish(src, staging, prefix); } catch (e) { if (e.message.includes(\"SHA-256 digest\")) { /* recompute digest, rename file, regenerate manifest */ } }","preventionTips":["Regenerate digest filenames from final asset bytes, never before the last edit","Automate manifest generation so paths always derive from computed hashes","Never edit an asset in place after generating current.json","Add a pre-publish sha256sum check comparing filenames to content digests"],"tags":["hash","announcements","integrity"],"backgroundTag":"checksum-mismatch","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}