Skip to content

Commit a6ca784

Browse files
authored
Merge pull request #657 from dev-five-git/owjs3901/du1-worktree-css-path-leak
fix(bun-plugin,rsbuild-plugin): stop baking absolute CSS paths into transformed code
2 parents e407524 + 8413631 commit a6ca784

8 files changed

Lines changed: 487 additions & 44 deletions

File tree

‎bun.lock‎

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs'
8+
import { tmpdir } from 'node:os'
9+
import { join, resolve } from 'node:path'
10+
11+
import { afterAll, expect, it } from 'bun:test'
12+
13+
// NOTE: named `*.bun.ts`, NOT `*.test.ts`, so the root suite (root = "packages",
14+
// source preload + 100% coverage gate) does not auto-discover it. Run it via
15+
// `bun run --filter @devup-ui/bun-plugin test:regression`; it needs the BUILT
16+
// plugin (dist/index.mjs) plus the WASM artifacts.
17+
//
18+
// Regression: developing one repository in several checkouts at once (git
19+
// worktrees, a CI matrix, sibling clones) used to make every checkout but the
20+
// first fail with
21+
//
22+
// error: Cannot find module '<other checkout>/df/devup-ui/devup-ui.css'
23+
// from '<this checkout>/src/Component.tsx'
24+
//
25+
// Bun stores transpiled modules in a machine-wide on-disk cache
26+
// (`<bun cache>/@t@`) keyed by module contents, with plugin-resolved import
27+
// specifiers already baked in; the key covers neither the cwd nor the importing
28+
// file. The checkouts hold byte-identical sources, so they share one cache
29+
// entry — and the plugin used to answer `onResolve` with
30+
// `<cwd>/df/devup-ui/devup-ui.css`, an absolute path that is only correct for
31+
// whichever checkout populated the entry first.
32+
//
33+
// This test drives the real failure: two checkouts whose fixture is byte for
34+
// byte the same (and padded past the size at which Bun persists transpiled
35+
// output), loaded by two separate `bun` processes that share that cache.
36+
37+
const pluginEntry = resolve(import.meta.dir, '..', 'dist', 'index.mjs')
38+
39+
// Byte-identical in both checkouts: that is what collapses them onto one cache
40+
// entry. `css()` is compile-only, so the plugin erases the @devup-ui/react
41+
// import entirely and the fixture needs no node_modules of its own — while the
42+
// extractor still injects the `df/devup-ui/devup-ui.css` import under test. The
43+
// dead exports pad the module past the size at which Bun persists transpiled
44+
// output (comments are stripped before hashing, so padding must be code).
45+
const fixture = [
46+
`import { css } from '@devup-ui/react'`,
47+
...Array.from(
48+
{ length: 4000 },
49+
(_, i) =>
50+
`export const pad${i} = 'devup-ui worktree isolation padding ${i}'`,
51+
),
52+
`export const cls = css({ background: 'red', padding: '4px' })`,
53+
'',
54+
].join('\n')
55+
56+
// A static import, loaded by `bun test` behind a preloaded plugin: the exact
57+
// shape in which consumers hit this — and the shape Bun caches.
58+
const fixtureTest = [
59+
`import { expect, it } from 'bun:test'`,
60+
``,
61+
`import { cls } from './fixture'`,
62+
``,
63+
`it('extracted its own stylesheet', () => {`,
64+
` console.log(JSON.stringify({ cwd: process.cwd(), cls }))`,
65+
` expect(cls).toBeTruthy()`,
66+
`})`,
67+
'',
68+
].join('\n')
69+
70+
const bunfig = `[test]\npreload = [${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}]\n`
71+
72+
const root = mkdtempSync(join(tmpdir(), 'devup-worktrees-'))
73+
74+
afterAll(() => {
75+
rmSync(root, { recursive: true, force: true })
76+
})
77+
78+
function makeCheckout(name: string) {
79+
const dir = join(root, name)
80+
mkdirSync(dir, { recursive: true })
81+
writeFileSync(join(dir, 'fixture.ts'), fixture, 'utf-8')
82+
writeFileSync(join(dir, 'fixture.test.ts'), fixtureTest, 'utf-8')
83+
writeFileSync(join(dir, 'bunfig.toml'), bunfig, 'utf-8')
84+
return dir
85+
}
86+
87+
function loadIn(dir: string) {
88+
const proc = Bun.spawnSync([process.execPath, 'test'], {
89+
cwd: dir,
90+
stdout: 'pipe',
91+
stderr: 'pipe',
92+
})
93+
const output = proc.stdout.toString() + proc.stderr.toString()
94+
const reported = /^\{"cwd".*\}$/m.exec(output)?.[0]
95+
return {
96+
exitCode: proc.exitCode,
97+
output,
98+
reported: reported
99+
? (JSON.parse(reported) as { cwd: string; cls: string })
100+
: undefined,
101+
}
102+
}
103+
104+
it('keeps two checkouts of one repository on their own stylesheet', () => {
105+
const checkoutA = makeCheckout('checkout-a')
106+
const checkoutB = makeCheckout('checkout-b')
107+
108+
// Sequential, sharing this machine's Bun transpiler cache: A populates the
109+
// entry, B reuses it.
110+
const first = loadIn(checkoutA)
111+
const second = loadIn(checkoutB)
112+
113+
for (const [dir, run] of [
114+
[checkoutA, first],
115+
[checkoutB, second],
116+
] as const) {
117+
expect(run.exitCode, `${dir} failed to load:\n${run.output}`).toBe(0)
118+
// Extraction really happened, so the injected stylesheet import — the thing
119+
// being resolved — was actually present in the module under test.
120+
expect(
121+
run.reported?.cls,
122+
`no extraction in ${dir}:\n${run.output}`,
123+
).toBeTruthy()
124+
expect(run.reported?.cwd).toBe(dir)
125+
// Each checkout materialised its own dist dir.
126+
expect(existsSync(join(dir, 'df', 'devup-ui'))).toBe(true)
127+
}
128+
129+
// Neither checkout may reach into the other. Before the fix this is precisely
130+
// where checkout B reported checkout A's absolute `df/devup-ui/devup-ui.css`.
131+
expect(first.output).not.toContain(checkoutB)
132+
expect(second.output).not.toContain(checkoutA)
133+
})

‎packages/bun-plugin/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"scripts": {
2323
"lint": "eslint",
2424
"build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external",
25-
"test:regression": "cd __regression__ && bun test ./preload-race.bun.ts"
25+
"test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts"
2626
},
2727
"publishConfig": {
2828
"access": "public"
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { join } from 'node:path'
2+
3+
import { describe, expect, it } from 'bun:test'
4+
5+
import { cssDirName, cssNamespace, resolveCssId } from '../css-id'
6+
7+
const distDir = 'df'
8+
9+
// Two checkouts of one repository, as produced by `git worktree add`. They hold
10+
// byte-identical sources at identical repository-relative paths and differ only
11+
// in their root, which is exactly the situation Bun's content-keyed transpiler
12+
// cache collapses into a single entry.
13+
const checkoutA = join('/repos', 'app', 'worktree-a')
14+
const checkoutB = join('/repos', 'app', 'worktree-b')
15+
const importerIn = (checkout: string) =>
16+
join(checkout, 'src', 'components', 'Card.tsx')
17+
18+
// The specifier the extractor injects: relative to the importing file.
19+
const injectedSpecifier = '../../df/devup-ui/devup-ui.css'
20+
21+
describe('resolveCssId', () => {
22+
it('resolves the injected stylesheet onto the virtual namespace', () => {
23+
expect(
24+
resolveCssId(injectedSpecifier, importerIn(checkoutA), distDir),
25+
).toEqual({
26+
path: 'devup-ui.css',
27+
namespace: cssNamespace,
28+
})
29+
})
30+
31+
it('resolves numbered per-file stylesheets', () => {
32+
expect(
33+
resolveCssId(
34+
'../../df/devup-ui/devup-ui-12.css',
35+
importerIn(checkoutA),
36+
distDir,
37+
),
38+
).toEqual({
39+
path: 'devup-ui-12.css',
40+
namespace: cssNamespace,
41+
})
42+
})
43+
44+
it('strips a query suffix from the stylesheet name', () => {
45+
expect(
46+
resolveCssId(
47+
'../../df/devup-ui/devup-ui.css?inline',
48+
importerIn(checkoutA),
49+
distDir,
50+
),
51+
).toEqual({
52+
path: 'devup-ui.css',
53+
namespace: cssNamespace,
54+
})
55+
})
56+
57+
it('resolves against the cwd when there is no importer', () => {
58+
expect(
59+
resolveCssId(
60+
join(distDir, cssDirName, 'devup-ui.css'),
61+
undefined,
62+
distDir,
63+
),
64+
).toEqual({
65+
path: 'devup-ui.css',
66+
namespace: cssNamespace,
67+
})
68+
})
69+
70+
// --- The regression this module exists for -------------------------------
71+
//
72+
// Bun stores transpiled modules in a machine-wide cache keyed by module
73+
// contents, with plugin-resolved specifiers baked in and neither the cwd nor
74+
// the importer in the key. Any id that varies per checkout therefore leaks
75+
// into the other checkout as
76+
// "Cannot find module '<other checkout>/df/devup-ui/devup-ui.css'".
77+
78+
it('yields the same, path-free id for two checkouts of one repository', () => {
79+
const fromA = resolveCssId(
80+
injectedSpecifier,
81+
importerIn(checkoutA),
82+
distDir,
83+
)
84+
const fromB = resolveCssId(
85+
injectedSpecifier,
86+
importerIn(checkoutB),
87+
distDir,
88+
)
89+
90+
expect(fromA).toEqual(fromB)
91+
// Nothing checkout-specific may survive into the resolved id.
92+
expect(fromA?.path).not.toContain(checkoutA)
93+
expect(fromB?.path).not.toContain(checkoutB)
94+
})
95+
96+
it('repairs a foreign absolute path baked in by an older plugin version', () => {
97+
// What a poisoned cache entry hands back: checkout A's absolute stylesheet
98+
// path, replayed while checkout B is the one being loaded.
99+
const poisoned = join(checkoutA, distDir, cssDirName, 'devup-ui.css')
100+
101+
expect(resolveCssId(poisoned, importerIn(checkoutB), distDir)).toEqual({
102+
path: 'devup-ui.css',
103+
namespace: cssNamespace,
104+
})
105+
})
106+
107+
// --- Stylesheets that are not ours ---------------------------------------
108+
109+
it('ignores a devup-ui.css that does not live in the dist css dir', () => {
110+
expect(
111+
resolveCssId('../../vendor/devup-ui.css', importerIn(checkoutA), distDir),
112+
).toBeUndefined()
113+
})
114+
115+
it('ignores a css dir nested under a different dist dir', () => {
116+
expect(
117+
resolveCssId(
118+
'../../other/devup-ui/devup-ui.css',
119+
importerIn(checkoutA),
120+
distDir,
121+
),
122+
).toBeUndefined()
123+
})
124+
125+
it('ignores a differently named stylesheet in the dist css dir', () => {
126+
expect(
127+
resolveCssId(
128+
'../../df/devup-ui/theme.css',
129+
importerIn(checkoutA),
130+
distDir,
131+
),
132+
).toBeUndefined()
133+
})
134+
135+
it('ignores an empty specifier', () => {
136+
expect(resolveCssId('', importerIn(checkoutA), distDir)).toBeUndefined()
137+
})
138+
})

0 commit comments

Comments
 (0)