Skip to content

Commit f6ee4ef

Browse files
committed
fix(blobs): Encode blob key parts before saving to disk on windows (#316)
* chore: add test for weird keys * test: add some even weirder keys * chore: install only chromium * test: add log when key set fails * fix(blobs): encode path keys as well as store keys when saving and loading blobs * fix(blobs): encode more names on win32 * fix(blobs): disallow more invalid win32 names
1 parent f52f332 commit f6ee4ef

File tree

5 files changed

+125
-12
lines changed

5 files changed

+125
-12
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
- name: Install dependencies
4949
run: npm ci
5050
- name: Install playwright browsers
51-
run: npx playwright install --with-deps
51+
run: npx playwright install --with-deps chromium
5252
- name: Build
5353
run: npm run build --workspaces=true
5454
- name: Tests

packages/blobs/src/server.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,44 @@ test('Reads and writes from the file system', async () => {
149149
await fs.rm(directory2.path, { force: true, recursive: true })
150150
})
151151

152+
test('Works with weird keys -- valid blob keys, tricky in filesystems', async () => {
153+
const directory = await tmp.dir()
154+
const server = new BlobsServer({
155+
directory: directory.path,
156+
token,
157+
})
158+
const { port } = await server.start()
159+
const store = getStore({
160+
edgeURL: `http://localhost:${port}`,
161+
name: 'store',
162+
token,
163+
siteID,
164+
})
165+
166+
// valid as keys and store names
167+
const pipes = 'valid|key'
168+
const question = 'key?'
169+
const glob = '*hehe*'
170+
const con = 'CON'
171+
const emoji = '🐰'
172+
const angles = '<>'
173+
// valid only as keys
174+
const slashes = 'a///b'
175+
const namespace = 'name:space'
176+
for (const key of [pipes, question, glob, con, angles, emoji, slashes, namespace]) {
177+
try {
178+
await store.set(key, 'value')
179+
expect(await store.get(key)).toBe('value')
180+
} catch (error) {
181+
console.warn(`couldn't set valid key of "${key}"`)
182+
throw error
183+
}
184+
}
185+
186+
await server.stop()
187+
await fs.rm(directory.path, { force: true, recursive: true })
188+
})
189+
152190
test('Separates keys from different stores', async () => {
153191
const directory = await tmp.dir()
154192
const server = new BlobsServer({

packages/blobs/src/server.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ import { createHmac } from 'node:crypto'
22
import { createReadStream, promises as fs } from 'node:fs'
33
import { tmpdir } from 'node:os'
44
import { dirname, join, relative, resolve, sep } from 'node:path'
5-
import { platform } from 'node:process'
65

76
import { HTTPServer } from '@netlify/dev-utils'
87

98
import { ListResponse } from './backend/list.ts'
109
import { SIGNED_URL_ACCEPT_HEADER } from './client.ts'
1110
import { decodeMetadata, encodeMetadata, METADATA_HEADER_INTERNAL } from './metadata.ts'
1211
import { HTTPMethod } from './types.ts'
13-
import { isNodeError, Logger } from './util.ts'
12+
import { decodeName, encodeName, isNodeError, Logger } from './util.ts'
1413

1514
const API_URL_PATH = /\/api\/v1\/blobs\/(?<site_id>[^/]+)\/(?<store_name>[^/]+)\/?(?<key>[^?]*)/
1615
const LEGACY_API_URL_PATH = /\/api\/v1\/sites\/(?<site_id>[^/]+)\/blobs\/?(?<key>[^?]*)/
@@ -231,7 +230,7 @@ export class BlobsServer {
231230
req: Request
232231
url: URL
233232
}): Promise<Response> {
234-
const { dataPath, rootPath, req, url } = options
233+
const { dataPath, rootPath, url } = options
235234
const directories = url.searchParams.get('directories') === 'true'
236235
const prefix = url.searchParams.get('prefix') ?? ''
237236
const result: ListResponse = {
@@ -259,10 +258,7 @@ export class BlobsServer {
259258
private async listStores(rootPath: string, prefix: string): Promise<Response> {
260259
try {
261260
const allStores = await fs.readdir(rootPath)
262-
const filteredStores = allStores
263-
// Store names are URI-encoded on Windows, so we must decode them first.
264-
.map((store) => (platform === 'win32' ? decodeURIComponent(store) : store))
265-
.filter((store) => store.startsWith(prefix))
261+
const filteredStores = allStores.map(decodeName).filter((store) => store.startsWith(prefix))
266262

267263
return Response.json({ stores: filteredStores })
268264
} catch (error) {
@@ -364,7 +360,7 @@ export class BlobsServer {
364360
parts = parts.slice(1)
365361
}
366362

367-
const [siteID, rawStoreName, ...key] = parts
363+
const [siteID, rawStoreName, ...rawKey] = parts
368364

369365
if (!siteID) {
370366
return {}
@@ -376,9 +372,9 @@ export class BlobsServer {
376372
return { rootPath }
377373
}
378374

379-
// On Windows, file paths can't include the `:` character, so we URI-encode
380-
// them.
381-
const storeName = platform === 'win32' ? encodeURIComponent(rawStoreName) : rawStoreName
375+
const key = rawKey.map(encodeName)
376+
377+
const storeName = encodeName(rawStoreName)
382378
const storePath = resolve(rootPath, storeName)
383379
const dataPath = resolve(storePath, ...key)
384380
const metadataPath = resolve(this.directory, 'metadata', siteID, storeName, ...key)

packages/blobs/src/util.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { it, expect, describe } from 'vitest'
2+
3+
import { decodeWin32SafeName, encodeWin32SafeName } from './util.ts'
4+
5+
describe('win32 safe names', () => {
6+
it('encodes unsafe path parts', () => {
7+
const unsafe = 'hello|*<>world'
8+
const safe = encodeWin32SafeName(unsafe)
9+
expect(safe).not.toContain('|')
10+
expect(safe).not.toContain('.')
11+
expect(safe).not.toContain('*')
12+
expect(safe).not.toContain('<')
13+
expect(safe).not.toContain('>')
14+
})
15+
16+
it('disallows invalid names', () => {
17+
expect(encodeWin32SafeName('CON')).not.toBe('CON')
18+
expect(encodeWin32SafeName('COM1')).not.toBe('COM1')
19+
expect(encodeWin32SafeName('com2')).not.toBe('com2')
20+
expect(encodeWin32SafeName('NUL')).not.toBe('NUL')
21+
expect(encodeWin32SafeName('PRN')).not.toBe('PRN')
22+
expect(encodeWin32SafeName('LPT3')).not.toBe('LPT3')
23+
24+
// no false positives
25+
expect(encodeWin32SafeName('annuling')).toBe('annuling')
26+
})
27+
28+
it('replaces end dots', () => {
29+
const safe = encodeWin32SafeName('hello.')
30+
expect(safe).not.toMatch(/\.$/)
31+
})
32+
33+
it('replaces end spaces', () => {
34+
const safe = encodeWin32SafeName('hehe ')
35+
expect(safe).not.toMatch(/\s+$/)
36+
})
37+
38+
it('can be reversed', () => {
39+
const unsafe = 'hello|.*<>world'
40+
const safe = encodeWin32SafeName(unsafe)
41+
expect(decodeWin32SafeName(safe)).toEqual(unsafe)
42+
})
43+
})

packages/blobs/src/util.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import process from 'node:process'
12
import { NF_ERROR, NF_REQUEST_ID } from './headers.ts'
23

34
export class BlobsInternalError extends Error {
@@ -27,3 +28,38 @@ export const collectIterator = async <T>(iterator: AsyncIterable<T>): Promise<T[
2728
export const isNodeError = (error: unknown): error is NodeJS.ErrnoException => error instanceof Error
2829

2930
export type Logger = (...message: unknown[]) => void
31+
32+
function percentEncode(str: string): string {
33+
return str.replace(/./, (char) => {
34+
return '%' + char.charCodeAt(0).toString(16).padStart(2, '0')
35+
})
36+
}
37+
38+
const invalidWin32File = /^(CON|COM[1-9]|LPT[1-9]|NUL|PRN|AUX)$/i
39+
40+
/*
41+
* On Windows, file paths can't include some valid blob/store key characters, so we URI-encode them. fixme: limitations
42+
*
43+
* Limitations:
44+
* - this doesn't deal with long names (blob keys can be 600 chars, default on windows is max 260)
45+
* For keys (which we don't need to decode) maybe a hash would be a better idea
46+
*/
47+
export function encodeWin32SafeName(string: string): string {
48+
if (invalidWin32File.exec(string)) {
49+
return percentEncode(string)
50+
}
51+
return encodeURIComponent(string).replace(/([*]|[. ]$)/g, percentEncode)
52+
}
53+
54+
// Names are URI-encoded on Windows, so we must decode them first.
55+
export function decodeWin32SafeName(string: string): string {
56+
return decodeURIComponent(string)
57+
}
58+
59+
export function encodeName(string: string): string {
60+
return process.platform == 'win32' ? encodeWin32SafeName(string) : string
61+
}
62+
63+
export function decodeName(string: string): string {
64+
return process.platform == 'win32' ? decodeWin32SafeName(string) : string
65+
}

0 commit comments

Comments
 (0)