diff --git a/scripts/generate-social-preview.mjs b/scripts/generate-social-preview.mjs index 5312f46..549d696 100644 --- a/scripts/generate-social-preview.mjs +++ b/scripts/generate-social-preview.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node -import { mkdir, realpath } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { mkdir, open, realpath, rename, rm } from 'node:fs/promises'; import { dirname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; @@ -30,6 +32,46 @@ const canonicalizeForWrite = async (path) => { const isInside = (path, root) => path === root || path.startsWith(`${root}${sep}`); +const writeRegularFileWithoutFollowingLinks = async (output, bytes) => { + if (typeof constants.O_NOFOLLOW !== 'number') fail(); + const destinationFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW + | (constants.O_NONBLOCK ?? 0); + let destination; + try { + destination = await open(output, destinationFlags, 0o600); + const stats = await destination.stat(); + if (!stats.isFile() || stats.nlink !== 1) fail(); + } catch { + fail(); + } finally { + await destination?.close(); + } + + const temporaryPath = `${output}.${randomUUID()}.tmp`; + let temporary; + let installed = false; + try { + temporary = await open( + temporaryPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + const stats = await temporary.stat(); + if (!stats.isFile() || stats.nlink !== 1) fail(); + await temporary.writeFile(bytes); + await temporary.sync(); + await temporary.close(); + temporary = undefined; + await rename(temporaryPath, output); + installed = true; + } catch { + fail(); + } finally { + await temporary?.close(); + if (!installed) await rm(temporaryPath, { force: true }); + } +}; + export const generateSocialPreview = async (outputPath) => { if (typeof outputPath !== 'string' || outputPath.trim() === '') fail(); const output = await canonicalizeForWrite(outputPath); @@ -53,11 +95,12 @@ export const generateSocialPreview = async (outputPath) => { `); - await mkdir(dirname(output), { recursive: true }); - await sharp(svg, { density: 72 }) + const bytes = await sharp(svg, { density: 72 }) .flatten({ background: '#070910' }) .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false }) - .toFile(output); + .toBuffer(); + await mkdir(dirname(output), { recursive: true }); + await writeRegularFileWithoutFollowingLinks(output, bytes); return output; }; diff --git a/tests/integration/prepare-assets.test.ts b/tests/integration/prepare-assets.test.ts index f85d083..359f944 100644 --- a/tests/integration/prepare-assets.test.ts +++ b/tests/integration/prepare-assets.test.ts @@ -320,6 +320,55 @@ describe('deterministic asset preparation', () => { .rejects.toThrow(/SOCIAL-PREVIEW-PATH-001/u); }); + it('rejects a leaf symlink without changing its out-of-root target', async () => { + const root = await createRoot(); + const outsideRoot = await mkdtemp(join(process.cwd(), '.portfolio-social-preview-test-')); + const targetPath = join(outsideRoot, 'protected-target.png'); + const outputPath = join(root, 'social-preview.png'); + const originalTarget = Buffer.from('outside target must remain unchanged'); + let error: unknown; + let targetHashBefore = ''; + let targetHashAfter = ''; + try { + await writeFile(targetPath, originalTarget); + targetHashBefore = await hashFile(targetPath); + await symlink(targetPath, outputPath); + try { + await generateSocialPreview(outputPath); + } catch (caught) { + error = caught; + } + targetHashAfter = await hashFile(targetPath); + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + + expect.soft(error).toBeInstanceOf(Error); + expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u); + expect(targetHashAfter).toBe(targetHashBefore); + }); + + it('rejects a hard-linked destination without changing the linked file', async () => { + const root = await createRoot(); + const targetPath = join(root, 'protected-target.png'); + const outputPath = join(root, 'social-preview.png'); + const originalTarget = Buffer.from('hard-linked target must remain unchanged'); + await writeFile(targetPath, originalTarget); + const targetHashBefore = await hashFile(targetPath); + await link(targetPath, outputPath); + + let error: unknown; + try { + await generateSocialPreview(outputPath); + } catch (caught) { + error = caught; + } + + expect.soft(error).toBeInstanceOf(Error); + expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u); + expect(await hashFile(targetPath)).toBe(targetHashBefore); + }); + it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => { const root = await createRoot(); const inputs = await makeInputs(root, baseRecipe());