From e0442fba00bf9b581f3d6b65a1a935af2faef189 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Tue, 9 Jun 2026 14:53:48 -0700 Subject: [PATCH] fix(brainstorm-server): ignore macOS resource-fork dotfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS (and ExFAT/SMB volumes) the OS writes ._.html sidecar files holding binary resource-fork metadata. These end with .html, so they passed the content filter and could be picked as the newest screen — serving binary garbage to the browser instead of the mockup — or fetched via /files/. Skip dotfiles (leading '.') at all four sites that list or serve content: getNewestScreen, the /files/ endpoint, the known-files seed, and the fs.watch handler. Tests cover serving (/ and /files/) and the watch path (a ._ file must not trigger a reload). Refs #950 --- skills/brainstorming/scripts/server.cjs | 12 ++++----- tests/brainstorm-server/server.test.js | 35 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index 79661a99..78bf7c37 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -124,7 +124,7 @@ function wrapInFrame(content) { function getNewestScreen() { const files = fs.readdirSync(CONTENT_DIR) - .filter(f => f.endsWith('.html')) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) .map(f => { const fp = path.join(CONTENT_DIR, f); return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; @@ -152,9 +152,9 @@ function handleRequest(req, res) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } else if (req.method === 'GET' && req.url.startsWith('/files/')) { - const fileName = req.url.slice(7); - const filePath = path.join(CONTENT_DIR, path.basename(fileName)); - if (!fs.existsSync(filePath)) { + const fileName = path.basename(req.url.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + if (fileName.startsWith('.') || !fs.existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; @@ -276,14 +276,14 @@ function startServer() { // macOS fs.watch reports 'rename' for both new files and overwrites, // so we can't rely on eventType alone. const knownFiles = new Set( - fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html')) + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) ); const server = http.createServer(handleRequest); server.on('upgrade', handleUpgrade); const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { - if (!filename || !filename.endsWith('.html')) return; + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); debounceTimers.set(filename, setTimeout(() => { diff --git a/tests/brainstorm-server/server.test.js b/tests/brainstorm-server/server.test.js index 2cccf095..52b3ffad 100644 --- a/tests/brainstorm-server/server.test.js +++ b/tests/brainstorm-server/server.test.js @@ -179,6 +179,25 @@ async function runTests() { assert(!res.body.includes('"not"'), 'Should not serve JSON'); }); + await test('ignores macOS resource-fork dotfiles (._*.html) when serving', async () => { + // On macOS/ExFAT/SMB, the OS writes ._name.html sidecar files holding + // binary metadata. They end with .html but must never be served as a screen. + fs.writeFileSync(path.join(CONTENT_DIR, 'real-screen.html'), '

Real Screen Content

'); + await sleep(100); + fs.writeFileSync(path.join(CONTENT_DIR, '._real-screen.html'), 'Mac OS X resource fork garbage'); + await sleep(300); + + const res = await fetch(`http://localhost:${TEST_PORT}/`); + assert(res.body.includes('Real Screen Content'), 'should serve the real screen, not the newer ._ sidecar'); + assert(!res.body.includes('resource fork garbage'), 'must not serve ._*.html dotfile content'); + }); + + await test('does not serve dotfiles via /files/', async () => { + fs.writeFileSync(path.join(CONTENT_DIR, '._secret.html'), 'dotfile body should not be served'); + const res = await fetch(`http://localhost:${TEST_PORT}/files/._secret.html`); + assert.strictEqual(res.status, 404, '/files/ must 404 on dotfiles'); + }); + await test('returns 404 for non-root paths', async () => { const res = await fetch(`http://localhost:${TEST_PORT}/other`); assert.strictEqual(res.status, 404); @@ -350,6 +369,22 @@ async function runTests() { ws.close(); }); + await test('does NOT send reload for ._*.html resource-fork dotfiles', async () => { + const ws = new WebSocket(`ws://localhost:${TEST_PORT}`); + await new Promise(resolve => ws.on('open', resolve)); + + let gotReload = false; + ws.on('message', (data) => { + if (JSON.parse(data.toString()).type === 'reload') gotReload = true; + }); + + fs.writeFileSync(path.join(CONTENT_DIR, '._sidecar.html'), 'resource fork'); + await sleep(500); + + assert(!gotReload, 'a ._ dotfile appearing must not trigger a reload'); + ws.close(); + }); + await test('clears state/events on new screen', async () => { // Create an events file const eventsFile = path.join(STATE_DIR, 'events');