Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/runtime/internal/database.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,12 @@ function refineDatabaseConfig(config: RuntimeConfig['content']['database']) {
}

if ('filename' in config) {
// URL pathnames stay percent-encoded, so decode them to get a filesystem path.
// Otherwise a space in a parent directory creates a literal `%20` directory.
// Escape bare percent signs while preserving already-encoded characters.
const filename = isAbsolute(config?.filename || '') || config?.filename === ':memory:'
? config?.filename
: new URL(config.filename, (globalThis as unknown as { _importMeta_: { url: string } })._importMeta_.url).pathname
: decodeURIComponent(new URL(config.filename.replace(/%(?![\da-f]{2})/gi, '%25'), (globalThis as unknown as { _importMeta_: { url: string } })._importMeta_.url).pathname)

_config.path = process.platform === 'win32' && filename.startsWith('/') ? filename.slice(1) : filename
}
Expand Down
63 changes: 63 additions & 0 deletions test/unit/database.server.filename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, test, vi } from 'vitest'
import type { RuntimeConfig } from '@nuxt/content'

/**
* A relative SQLite `filename` is resolved against the server entry's file URL.
* URL pathnames keep percent-encoding, so the path must be decoded before it is
* handed to the connector. Otherwise a project under `/Users/me/My Projects`
* writes its database to a new literal `/Users/me/My%20Projects` directory.
*/
describe.skipIf(process.platform === 'win32')('database.server - sqlite filename', () => {
const serverDir = '/tmp/My Projects/CafΓ© #1/100% ready/server'

afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

async function resolvePath(filename: string) {
const adapterFn = vi.fn((_opts: { path?: string }) => ({ prepare: vi.fn() }))
vi.doMock('#content/adapter', () => ({ default: adapterFn }))
vi.doMock('#content/local-adapter', () => ({ default: vi.fn(() => ({ prepare: vi.fn() })) }))
vi.stubGlobal('_importMeta_', { url: pathToFileURL(`${serverDir}/index.mjs`).href })

const config = {
database: { type: 'sqlite', filename },
localDatabase: { type: 'sqlite', filename: ':memory:' },
databaseVersion: 'test',
} as RuntimeConfig['content']

const loadDatabaseAdapter = (await import('../../src/runtime/internal/database.server')).default
await loadDatabaseAdapter(config)

expect(adapterFn).toHaveBeenCalledOnce()
return adapterFn.mock.calls[0]![0].path
}

test('decodes spaces, Unicode, # and % in parent directories', async () => {
expect(await resolvePath('./contents.sqlite')).toBe(`${serverDir}/contents.sqlite`)
})

test('decodes percent-encoded characters in the filename', async () => {
expect(await resolvePath('./literal%2520name.sqlite')).toBe(`${serverDir}/literal%20name.sqlite`)
})

test.each([
'./100% ready.sqlite',
'./contents.sqlite%',
'./contents%2.sqlite',
'./contents%GG.sqlite',
])('preserves literal percent signs in %s', async (filename) => {
expect(await resolvePath(filename)).toBe(`${serverDir}/${filename.slice(2)}`)
})

test('decodes encoded characters alongside literal percent signs once', async () => {
expect(await resolvePath('./100% Caf%c3%a9%20%2520.sqlite')).toBe(`${serverDir}/100% CafΓ© %20.sqlite`)
})

test('keeps absolute filenames unchanged', async () => {
expect(await resolvePath('/tmp/absolute path/contents.sqlite')).toBe('/tmp/absolute path/contents.sqlite')
})
})
Loading