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.
This commit is contained in:
ethernet
2026-09-12 19:00:49 -04:00
parent 1686e54d4f
commit 5c72dc0c6d
2 changed files with 26 additions and 3 deletions

View File

@@ -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')

View File

@@ -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')