From 5c72dc0c6d7a6e88031075770d57cc5ec76ee3ac Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:00:49 -0400 Subject: [PATCH] fix(build): protect symlinked desktop source inputs A source child can resolve outside the checkout. The output guard must protect its canonical path before the builder checks prepared inputs. Exclude generated dist/build trees so in-tree products can still rebuild. Keep explicit prepared inputs protected even inside generated trees. The public buildDesktop regression test first failed with a missing-icon error instead of an overlap error. All 7 desktop-builder tests now pass, including real cold/warm builds under apps/desktop/build/products. Node syntax checks and git diff --check pass. No full suite or native packaging ran. --- scripts/build/desktop.mjs | 8 +++++--- tests-js/desktop-builder.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index ddd650589e..9b0aaa0634 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -29,9 +29,11 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type if (!icons || !stamp || !nativeDeps) throw new Error('icons, stamp and nativeDeps are required prepared inputs') const app = 'apps/desktop' ;({ source, out } = productOutput(source, out, [ - // productOutput owns source/generated classification. Protect prepared - // inputs explicitly, including dependency symlinks outside the checkout. - `${app}/node_modules`, 'node_modules', + // Source children can be symlinks outside the checkout. Protect their + // canonical paths, but leave generated dist/build trees to productOutput. + ...readdirSync(join(resolve(source), app)).filter(name => !['dist', 'build'].includes(name)).map(name => `${app}/${name}`), + `${app}/scripts`, 'scripts/build', 'package.json', 'package-lock.json', + 'apps/shared', 'node_modules', ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), ])) const publicIcons = join(resolve(icons), app, 'public') diff --git a/tests-js/desktop-builder.test.mjs b/tests-js/desktop-builder.test.mjs index 6eac116656..5a44be30cf 100644 --- a/tests-js/desktop-builder.test.mjs +++ b/tests-js/desktop-builder.test.mjs @@ -97,6 +97,27 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false) }, 60000) +test('desktop output cannot overlap a source directory symlinked outside the checkout', async () => { + const { buildDesktop } = await import('../scripts/build/desktop.mjs') + const root = mkdtempSync(join(tmpdir(), 'desktop symlinked source-')) + roots.push(root) + const source = join(root, 'source') + const externalSource = join(root, 'external-source') + mkdirSync(join(source, 'apps/desktop'), { recursive: true }) + put(join(externalSource, 'index.js'), 'source must not change') + symlinkSync(externalSource, join(source, 'apps/desktop/src'), 'junction') + const out = join(externalSource, 'product') + const before = files(root) + + // Missing prepared inputs must not hide a failure to reject source overlap. + await expect(buildDesktop({ source, out, + icons: join(root, 'icons'), stamp: join(root, 'stamp.json'), nativeDeps: join(root, 'native'), + })).rejects.toThrow(/Output must not overlap build inputs/) + expect(files(root)).toEqual(before) + expect(readdirSync(externalSource)).toEqual(['index.js']) + expect(existsSync(out)).toBe(false) +}) + test('in-tree desktop products rebuild after build exists without replacing prepared inputs', async () => { const { buildDesktop } = await import('../scripts/build/desktop.mjs') const { productCurrent } = await import('../scripts/build/freshness.mjs')