Skip to content

Commit 6e24754

Browse files
authored
fix: version cached binary filename to detect stale/mismatched drivers (#733)
1 parent 81f1824 commit 6e24754

2 files changed

Lines changed: 266 additions & 10 deletions

File tree

src/install.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,35 @@ if (process.env.HTTPS_PROXY) {
2626
fetchOpts.agent = new HttpProxyAgent(process.env.HTTP_PROXY)
2727
}
2828

29+
// Only allow characters that are safe as a filename segment.
30+
// Rejects path separators (/ \) and any traversal sequences.
31+
const SAFE_VERSION_RE = /^[a-zA-Z0-9._-]+$/
32+
33+
export function getBinaryFilename (version: string) {
34+
if (!SAFE_VERSION_RE.test(version)) {
35+
throw new Error(`Invalid geckodriver version string: ${JSON.stringify(version)}`)
36+
}
37+
return `geckodriver-${version}` + (os.platform() === 'win32' ? '.exe' : '')
38+
}
39+
2940
export async function download (
3041
geckodriverVersion: string = process.env.GECKODRIVER_VERSION,
3142
cacheDir: string = process.env.GECKODRIVER_CACHE_DIR || os.tmpdir()
3243
) {
33-
const binaryFilePath = path.resolve(cacheDir, BINARY_FILE)
34-
if (await hasAccess(binaryFilePath)) {
35-
return binaryFilePath
44+
/**
45+
* If the version is already known, check the versioned cache path first.
46+
* This is the hot path: zero network requests when the binary is cached.
47+
*/
48+
if (geckodriverVersion) {
49+
const cachedPath = path.resolve(cacheDir, getBinaryFilename(geckodriverVersion))
50+
if (await hasAccess(cachedPath)) {
51+
return cachedPath
52+
}
3653
}
3754

3855
/**
39-
* get latest version of Geckodriver
56+
* Version is unknown — fetch the latest release from Cargo.toml, then
57+
* check the versioned cache before hitting the network for the binary.
4058
*/
4159
if (!geckodriverVersion) {
4260
const res = await retryFetch(GECKODRIVER_CARGO_YAML, fetchOpts)
@@ -47,8 +65,15 @@ export async function download (
4765
}
4866
geckodriverVersion = version.split(' = ').pop().slice(1, -1)
4967
log.info(`Detected Geckodriver v${geckodriverVersion} to be latest`)
68+
69+
// the resolved latest may already be cached
70+
const cachedPath = path.resolve(cacheDir, getBinaryFilename(geckodriverVersion))
71+
if (await hasAccess(cachedPath)) {
72+
return cachedPath
73+
}
5074
}
5175

76+
const binaryFilePath = path.resolve(cacheDir, getBinaryFilename(geckodriverVersion))
5277
const url = getDownloadUrl(geckodriverVersion)
5378
log.info(`Downloading Geckodriver from ${url}`)
5479
const res = await retryFetch(url, fetchOpts)
@@ -58,22 +83,56 @@ export async function download (
5883
}
5984

6085
await fsp.mkdir(cacheDir, { recursive: true })
61-
await (url.endsWith('.zip')
62-
? downloadZip(res, cacheDir)
63-
: pipeline(res.body, zlib.createGunzip(), unpackTar(cacheDir)))
86+
87+
// Extract into a unique per-operation staging directory so concurrent
88+
// downloads (even of the same version, e.g. parallel test runners) never
89+
// share intermediate files. The binary is moved into its final versioned
90+
// location once extraction succeeds.
91+
const stagingDir = await fsp.mkdtemp(path.join(cacheDir, 'geckodriver-'))
92+
try {
93+
await (url.endsWith('.zip')
94+
? downloadZip(res, stagingDir)
95+
: pipeline(res.body, zlib.createGunzip(), unpackTar(stagingDir)))
96+
97+
// archives always extract the binary with the generic name; rename to the
98+
// versioned filename so future cache lookups resolve to the correct version
99+
try {
100+
await fsp.rename(path.resolve(stagingDir, BINARY_FILE), binaryFilePath)
101+
} catch (err) {
102+
// a concurrent download of the same version may have produced the
103+
// final binary already; on Windows rename throws EEXIST/EPERM in
104+
// that case. Treat it as success if the destination is accessible.
105+
const code = (err as NodeJS.ErrnoException)?.code
106+
if ((code === 'EEXIST' || code === 'EPERM') && await hasAccess(binaryFilePath)) {
107+
return binaryFilePath
108+
}
109+
throw err
110+
}
111+
} finally {
112+
await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {})
113+
}
64114

65115
await fsp.chmod(binaryFilePath, '755')
66116
return binaryFilePath
67117
}
68118

69-
async function downloadZip(res: Awaited<ReturnType<typeof retryFetch>>, cacheDir: string) {
119+
async function downloadZip(res: Awaited<ReturnType<typeof retryFetch>>, stagingDir: string) {
70120
const zipBlob = await res.blob()
71121
const zip = new ZipReader(new BlobReader(zipBlob))
122+
const resolvedStagingDir = path.resolve(stagingDir)
72123
for (const entry of await zip.getEntries()) {
73-
const unzippedFilePath = path.join(cacheDir, entry.filename)
124+
const unzippedFilePath = path.join(stagingDir, entry.filename)
74125
if (entry.directory) {
75126
continue
76127
}
128+
/**
129+
* guard against Zip Slip: a malicious archive could contain entries
130+
* with `../` or absolute paths that escape the staging directory
131+
*/
132+
const resolvedPath = path.resolve(unzippedFilePath)
133+
if (resolvedPath !== resolvedStagingDir && !resolvedPath.startsWith(resolvedStagingDir + path.sep)) {
134+
throw new Error(`Zip entry "${entry.filename}" resolves outside the staging directory`)
135+
}
77136
const fileEntry = entry as FileEntry
78137
if (!await hasAccess(path.dirname(unzippedFilePath))) {
79138
await fsp.mkdir(path.dirname(unzippedFilePath), { recursive: true })

tests/unit.test.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import os from 'node:os'
2-
import { vi, test, expect, afterEach } from 'vitest'
2+
import path from 'node:path'
3+
import fsp from 'node:fs/promises'
4+
import { vi, test, expect, describe, beforeEach, afterEach } from 'vitest'
35

46
import { getDownloadUrl, parseParams, retryFetch } from '../src/utils.js'
7+
import { getBinaryFilename, download } from '../src/install.js'
58

9+
// All vi.mock calls must be at module scope so Vitest hoists them before
10+
// any imports — mocks inside test() bodies are not hoisted and the already-
11+
// bound module closures (e.g. pipeline, unpackTar in install.js) would not
12+
// see the mock.
613
vi.mock('node:os', () => ({
714
default: {
815
arch: vi.fn(),
@@ -11,6 +18,43 @@ vi.mock('node:os', () => ({
1118
}
1219
}))
1320

21+
vi.mock('node:fs/promises', () => ({
22+
default: {
23+
access: vi.fn(),
24+
mkdir: vi.fn().mockResolvedValue(undefined),
25+
mkdtemp: vi.fn(),
26+
rename: vi.fn().mockResolvedValue(undefined),
27+
chmod: vi.fn().mockResolvedValue(undefined),
28+
rm: vi.fn().mockResolvedValue(undefined),
29+
},
30+
writeFile: vi.fn().mockResolvedValue(undefined),
31+
}))
32+
33+
vi.mock('../src/utils.js', async (original) => {
34+
const actual: any = await original()
35+
return {
36+
...actual,
37+
hasAccess: vi.fn(),
38+
}
39+
})
40+
41+
vi.mock('node:stream/promises', () => ({
42+
pipeline: vi.fn().mockResolvedValue(undefined),
43+
}))
44+
45+
vi.mock('modern-tar/fs', () => ({
46+
unpackTar: vi.fn().mockReturnValue(vi.fn()),
47+
}))
48+
49+
const zipState = vi.hoisted(() => ({ entries: [] as any[] }))
50+
vi.mock('@zip.js/zip.js', () => ({
51+
BlobReader: class { },
52+
BlobWriter: class { },
53+
ZipReader: class {
54+
getEntries() { return Promise.resolve(zipState.entries) }
55+
},
56+
}))
57+
1458
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
1559
status: 400,
1660
text: () => Promise.resolve('foobar'),
@@ -21,6 +65,23 @@ afterEach(() => {
2165
vi.mocked(globalThis.fetch).mockReset()
2266
})
2367

68+
test('getBinaryFilename includes version in the filename', () => {
69+
vi.mocked(os.platform).mockReturnValue('linux')
70+
expect(getBinaryFilename('0.36.0')).toBe('geckodriver-0.36.0')
71+
72+
vi.mocked(os.platform).mockReturnValue('darwin')
73+
expect(getBinaryFilename('0.36.0')).toBe('geckodriver-0.36.0')
74+
75+
vi.mocked(os.platform).mockReturnValue('win32')
76+
expect(getBinaryFilename('0.36.0')).toBe('geckodriver-0.36.0.exe')
77+
})
78+
79+
test('getBinaryFilename rejects version strings with path separators or traversal sequences', () => {
80+
expect(() => getBinaryFilename('../../../evil')).toThrow('Invalid geckodriver version string')
81+
expect(() => getBinaryFilename('0.36.0/etc/passwd')).toThrow('Invalid geckodriver version string')
82+
expect(() => getBinaryFilename('0.36.0\\evil')).toThrow('Invalid geckodriver version string')
83+
})
84+
2485
test('getDownloadUrl', () => {
2586
vi.mocked(os.arch).mockReturnValue('arm')
2687
vi.mocked(os.platform).mockReturnValue('linux')
@@ -42,6 +103,142 @@ test('getDownloadUrl', () => {
42103
expect(getDownloadUrl('0.33.0')).toMatchSnapshot()
43104
})
44105

106+
describe('download caching behaviour', () => {
107+
const CACHE_DIR = path.resolve(os.tmpdir(), 'test-cache')
108+
let hasAccess: ReturnType<typeof vi.fn>
109+
110+
beforeEach(async () => {
111+
const utils = await import('../src/utils.js')
112+
hasAccess = vi.mocked(utils.hasAccess)
113+
114+
delete process.env.GECKODRIVER_VERSION
115+
vi.mocked(os.platform).mockReturnValue('linux')
116+
// default: cache miss
117+
hasAccess.mockResolvedValue(false)
118+
vi.mocked(fsp.rename).mockClear()
119+
vi.mocked(fsp.chmod).mockClear()
120+
vi.mocked(fsp.rm).mockClear()
121+
vi.mocked(fsp.mkdtemp).mockClear()
122+
})
123+
124+
test('returns cached versioned binary without any network request', async () => {
125+
vi.mocked(hasAccess).mockResolvedValue(true) // cache hit
126+
127+
const result = await download('0.36.0', CACHE_DIR)
128+
129+
expect(result).toBe(path.resolve(CACHE_DIR, 'geckodriver-0.36.0'))
130+
// zero network requests — the hot path must be purely local
131+
expect(globalThis.fetch).not.toHaveBeenCalled()
132+
})
133+
134+
test('does not fetch Cargo.toml when explicit version is cached', async () => {
135+
vi.mocked(hasAccess).mockResolvedValue(true)
136+
137+
await download('0.36.0', CACHE_DIR)
138+
139+
const cargoFetched = vi.mocked(globalThis.fetch).mock.calls
140+
.some(([url]) => String(url).includes('Cargo.toml'))
141+
expect(cargoFetched).toBe(false)
142+
})
143+
144+
test('resolves latest version from Cargo.toml then hits versioned cache (no binary download)', async () => {
145+
vi.mocked(globalThis.fetch).mockResolvedValue({
146+
status: 200,
147+
text: () => Promise.resolve('version = "0.36.0"\nother = "x"'),
148+
} as any)
149+
// after Cargo.toml resolves, the versioned binary is already cached
150+
vi.mocked(hasAccess).mockResolvedValue(true)
151+
152+
const result = await download(undefined, CACHE_DIR)
153+
154+
expect(result).toBe(path.resolve(CACHE_DIR, 'geckodriver-0.36.0'))
155+
// only Cargo.toml fetched — no binary download
156+
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
157+
expect(vi.mocked(globalThis.fetch).mock.calls[0][0]).toContain('Cargo.toml')
158+
})
159+
160+
test('extracts into a unique mkdtemp staging dir, then renames to the final versioned path', async () => {
161+
const stagingDir = path.resolve(CACHE_DIR, 'geckodriver-AbC123')
162+
vi.mocked(fsp.mkdtemp).mockResolvedValue(stagingDir)
163+
vi.mocked(globalThis.fetch).mockResolvedValue({
164+
status: 200,
165+
body: {},
166+
blob: vi.fn().mockResolvedValue(new Blob([])),
167+
} as any)
168+
169+
await download('0.36.0', CACHE_DIR).catch(() => {})
170+
171+
const finalPath = path.resolve(CACHE_DIR, 'geckodriver-0.36.0')
172+
173+
// staging dir must be created via mkdtemp (unique per operation), not a
174+
// deterministic path that parallel downloads of the same version share
175+
expect(fsp.mkdtemp).toHaveBeenCalledWith(path.join(CACHE_DIR, 'geckodriver-'))
176+
// rename must move the extracted binary out of that unique staging dir
177+
expect(fsp.rename).toHaveBeenCalledWith(
178+
path.resolve(stagingDir, 'geckodriver'), // extracted name inside staging dir
179+
finalPath // versioned final destination
180+
)
181+
// staging dir must be cleaned up
182+
expect(fsp.rm).toHaveBeenCalledWith(stagingDir, { recursive: true, force: true })
183+
})
184+
185+
test('concurrent downloads of the same version use isolated staging dirs', async () => {
186+
// each mkdtemp call returns a distinct directory, mirroring the real OS
187+
let counter = 0
188+
vi.mocked(fsp.mkdtemp).mockImplementation(async (prefix) =>
189+
`${prefix}${++counter}`
190+
)
191+
vi.mocked(globalThis.fetch).mockResolvedValue({
192+
status: 200,
193+
body: {},
194+
blob: vi.fn().mockResolvedValue(new Blob([])),
195+
} as any)
196+
197+
await Promise.all([
198+
download('0.36.0', CACHE_DIR).catch(() => {}),
199+
download('0.36.0', CACHE_DIR).catch(() => {}),
200+
])
201+
202+
const stagingDirs = vi.mocked(fsp.rename).mock.calls.map(([src]) => path.dirname(String(src)))
203+
expect(stagingDirs).toHaveLength(2)
204+
// the two concurrent operations must not share a staging directory
205+
expect(stagingDirs[0]).not.toBe(stagingDirs[1])
206+
})
207+
208+
test('treats EEXIST on the final rename as success when the binary is already present', async () => {
209+
vi.mocked(fsp.mkdtemp).mockResolvedValue(path.resolve(CACHE_DIR, 'geckodriver-xyz'))
210+
vi.mocked(globalThis.fetch).mockResolvedValue({
211+
status: 200, body: {}, blob: vi.fn().mockResolvedValue(new Blob([])),
212+
} as any)
213+
// cache miss on entry, but after the rename collision the binary exists
214+
hasAccess.mockResolvedValueOnce(false).mockResolvedValue(true)
215+
const renameErr: any = new Error('exists')
216+
renameErr.code = 'EEXIST'
217+
vi.mocked(fsp.rename).mockRejectedValueOnce(renameErr)
218+
219+
const result = await download('0.36.0', CACHE_DIR)
220+
221+
// a concurrent winner produced the binary — must resolve, not throw
222+
expect(result).toBe(path.resolve(CACHE_DIR, 'geckodriver-0.36.0'))
223+
})
224+
225+
test('rejects zip entries that escape the staging directory (Zip Slip)', async () => {
226+
const stagingDir = path.resolve(CACHE_DIR, 'geckodriver-zip')
227+
vi.mocked(os.platform).mockReturnValue('win32') // .zip download path
228+
vi.mocked(os.arch).mockReturnValue('x64')
229+
vi.mocked(fsp.mkdtemp).mockResolvedValue(stagingDir)
230+
vi.mocked(globalThis.fetch).mockResolvedValue({
231+
status: 200, body: {}, blob: vi.fn().mockResolvedValue(new Blob([])),
232+
} as any)
233+
234+
zipState.entries = [
235+
{ filename: '../../evil.exe', directory: false, getData: vi.fn() },
236+
]
237+
238+
await expect(download('0.36.0', CACHE_DIR)).rejects.toThrow('resolves outside the staging directory')
239+
})
240+
})
241+
45242
test('download with proxy support', async () => {
46243
// Ensure hasAccess always returns false so download always calls fetch
47244
vi.mock('../src/utils.js', async () => {

0 commit comments

Comments
 (0)