diff --git a/scripts/smoke-production.mjs b/scripts/smoke-production.mjs index 4138cdc..d051265 100644 --- a/scripts/smoke-production.mjs +++ b/scripts/smoke-production.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { pathToFileURL } from 'node:url'; +import { PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs'; const sha256Pattern = /^[a-f0-9]{64}$/u; const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu; @@ -144,6 +145,56 @@ async function request(fetchImpl, url, init = {}) { return response; } +async function cancelResponseBody(response) { + try { await response.body?.cancel(); } catch { /* Preserve the validation error that required cancellation. */ } +} + +async function countBoundedResponseBody(response, label, maxBytes) { + const contentLengthHeader = response.headers.get('content-length'); + let declaredBytes = null; + if (contentLengthHeader !== null) { + if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLengthHeader)) { + await cancelResponseBody(response); + throw new Error(`${label} content-length is malformed.`); + } + declaredBytes = Number(contentLengthHeader); + if (!Number.isSafeInteger(declaredBytes)) { + await cancelResponseBody(response); + throw new Error(`${label} content-length is outside the safe integer range.`); + } + if (declaredBytes === 0) { + await cancelResponseBody(response); + throw new Error(`${label} response is empty.`); + } + if (declaredBytes > maxBytes) { + await cancelResponseBody(response); + throw new Error(`${label} exceeds the ${maxBytes}-byte project-still limit.`); + } + } + if (response.body === null) throw new Error(`${label} response body is missing.`); + + const reader = response.body.getReader(); + let receivedBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value.byteLength; + if (receivedBytes > maxBytes) { + try { await reader.cancel(); } catch { /* Preserve the byte-limit validation error. */ } + throw new Error(`${label} exceeds the ${maxBytes}-byte project-still limit.`); + } + } + } finally { + reader.releaseLock(); + } + if (receivedBytes === 0) throw new Error(`${label} response is empty.`); + if (declaredBytes !== null && receivedBytes !== declaredBytes) { + throw new Error(`${label} content-length does not match the streamed byte count.`); + } + return receivedBytes; +} + export async function verifyProductionSmoke(options) { const canonical = rootHttpsUrl(options.canonicalUrl, 'Canonical URL'); if (!sha256Pattern.test(options.expectedManifestDigest)) throw new Error('Expected signed manifest digest must be lowercase SHA-256.'); @@ -182,7 +233,11 @@ export async function verifyProductionSmoke(options) { if (socialImageType === null || !new RegExp(`^${publicMetadata.socialImageType}(?:;|$)`, 'u').test(socialImageType)) { throw new Error('Production social image content-type is missing or unsupported.'); } - if ((await socialImageResponse.arrayBuffer()).byteLength === 0) throw new Error('Production social image response is empty.'); + await countBoundedResponseBody( + socialImageResponse, + 'Production social image', + PERFORMANCE_BUDGETS.projectStill, + ); for (const path of ['/robots.txt', '/sitemap.xml']) { const response = await request(fetchImpl, new URL(path, canonical)); if (response.status !== 200) throw new Error(`${path} did not return 200.`); diff --git a/tests/deployment/seo.spec.ts b/tests/deployment/seo.spec.ts index cc37943..3dfc67a 100644 --- a/tests/deployment/seo.spec.ts +++ b/tests/deployment/seo.spec.ts @@ -11,6 +11,8 @@ const alternate = 'http://www.portfolio.example/'; const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer'; const description = 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.'; const socialImage = `${canonical}assets/project-social-preview.webp`; +const projectStillMaxBytes = 400 * 1024; +const socialImageFixture = Buffer.alloc(15_068); const html = `