/** * Regression guard — the keepsake extracts to files a guest can actually open. * * `ZipEntryBuilder::new` leaves the external file attribute at zero, and async_zip's host * compatibility defaults to Unix — so every entry in BOTH archives was written with a stored mode * of 0000. `unzip -Z` showed `?---------` on every line. * * Windows Explorer ignores Unix modes, which is exactly why this survived. On Linux and macOS, * `unzip` faithfully applies what the archive asks for, and the guest gets a folder of photos none * of which they can open — plus an index.html the browser refuses with ERR_ACCESS_DENIED. * * Unconditional: it affected every keepsake ever produced, no hostile input required. And it is * invisible server-side — the export succeeds, the ZIP is well-formed, the job writes `done`, * /export/status is green. The only way to see it is to extract the real artifact and try to read * it, which is what this does. * * Found while chasing an unrelated ERR_ACCESS_DENIED that looked like a Playwright quirk. */ import { test, expect } from '../../fixtures/test'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, rmSync, readFileSync, statSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { seedUpload } from '../../helpers/seed'; import { BASE } from '../../helpers/env'; /** Walk every file under `dir`, ignoring the archives we dropped there ourselves. */ function walk(dir: string, skip: string[] = []): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = join(dir, e.name); if (e.isDirectory()) return walk(p, skip); return skip.includes(e.name) ? [] : [p]; }); } test.describe('Export — the archives extract to readable files', () => { test('every entry in both keepsake archives is owner-readable', async ({ host, guest, db }) => { test.setTimeout(120_000); const bearer = { Authorization: `Bearer ${host.jwt}` }; const g = await guest('Archivist'); const id = await seedUpload(g.jwt, { caption: 'ein Foto' }); await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done'); expect( (await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer })) .status ).toBe(204); await expect .poll( async () => { const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer }); const s = await res.json(); return s.zip?.status === 'done' && s.html?.status === 'done'; }, { timeout: 90_000, intervals: [500] } ) .toBe(true); for (const kind of ['zip', 'html'] as const) { const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer, }); const { ticket } = (await ticketRes.json()) as { ticket: string }; const dl = await fetch(`${BASE}/api/v1/export/${kind}?ticket=${encodeURIComponent(ticket)}`); expect(dl.status, `downloading the ${kind} archive`).toBe(200); const dir = mkdtempSync(join(tmpdir(), `eventsnap-perms-${kind}-`)); try { const zipPath = join(dir, 'archive.zip'); writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer())); // The mode as STORED in the archive — this is what a guest's unzip will apply. Reading it // from the central directory catches the defect even on a filesystem that would mask it. const listing = execFileSync('unzip', ['-Z', zipPath], { encoding: 'utf8' }); const modes = listing .split('\n') .filter((l) => /^[?d-][rwx-]{9}\s/.test(l)) .map((l) => l.slice(0, 10)); expect(modes.length, `${kind}: no entries listed`).toBeGreaterThan(0); for (const m of modes) { expect(m, `${kind}: an entry is stored mode ${m} — the guest cannot open it`).toMatch( /^.r[w-]-/ ); } // And extraction really does produce readable files. execFileSync('unzip', ['-qo', zipPath, '-d', dir]); const files = walk(dir, ['archive.zip']); expect(files.length, `${kind}: nothing extracted`).toBeGreaterThan(0); for (const f of files) { expect(statSync(f).mode & 0o400, `${f} is not owner-readable`).toBeTruthy(); // The assertion that matters to a guest: the bytes actually come out. expect(() => readFileSync(f)).not.toThrow(); } } finally { rmSync(dir, { recursive: true, force: true }); } } }); });