diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts index 3dbf49f..422d1e5 100644 --- a/src/main/library-path-config.ts +++ b/src/main/library-path-config.ts @@ -18,6 +18,21 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Normalize an administrator-supplied DLC_DIR and accept it only when it + * already names a directory. Invalid overrides must not shadow config.json. + */ +export function normalizeExplicitLibraryPath(rawPath?: string): string | undefined { + const candidate = (rawPath || '').trim(); + if (!candidate) return undefined; + + try { + return fs.statSync(candidate).isDirectory() ? candidate : undefined; + } catch { + return undefined; + } +} + /** * Prepare the library path contract for the Python backend. * @@ -31,7 +46,7 @@ export function prepareLibraryPathForPython( resolvedDlcDir: string, explicitDlcDir?: string, ): LibraryPathPreparation { - const override = (explicitDlcDir || '').trim(); + const override = normalizeExplicitLibraryPath(explicitDlcDir); if (override) { return { status: 'explicit-override', diff --git a/src/main/python.ts b/src/main/python.ts index 0cc07ab..a3eee35 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -11,7 +11,7 @@ import * as net from 'net'; import * as os from 'os'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { isDebugEnabled } from './debug-log'; -import { prepareLibraryPathForPython } from './library-path-config'; +import { normalizeExplicitLibraryPath, prepareLibraryPathForPython } from './library-path-config'; let pythonProcess: ChildProcess | null = null; // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL @@ -424,8 +424,8 @@ function getPluginsDir(): string { return pluginsDir; } -function getDLCDir(): string { - if (process.env.DLC_DIR && fs.existsSync(process.env.DLC_DIR)) return process.env.DLC_DIR; +function getDLCDir(explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR)): string { + if (explicitDlcDir) return explicitDlcDir; // Read from shared config const configFile = path.join(getConfigDir(), 'config.json'); @@ -497,7 +497,8 @@ export async function startPython(): Promise { } serverPort = await findPort(PREFERRED_PORT); const configDir = getConfigDir(); - const dlcDir = getDLCDir(); + const explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR); + const dlcDir = getDLCDir(explicitDlcDir); // Ensure the resolved library folder exists before the server starts. The // Python side only seeds starter content (and scans) when DLC_DIR.is_dir() // is true, and it can't bootstrap the folder itself (the seed's mkdir runs @@ -514,9 +515,6 @@ export async function startPython(): Promise { // instead. The backend re-reads that file for every scan, so a path saved in // Settings takes effect immediately rather than being shadowed by the // startup path until the Python process restarts. - const explicitDlcDir = process.env.DLC_DIR && fs.existsSync(process.env.DLC_DIR) - ? process.env.DLC_DIR - : undefined; const libraryPath = prepareLibraryPathForPython(configDir, dlcDir, explicitDlcDir); if (libraryPath.error) { console.warn(`[python] could not prepare dynamic library path (${libraryPath.status}): ${libraryPath.error}`); diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js index 28e1c65..6c786fb 100644 --- a/tests/library-path-config.test.js +++ b/tests/library-path-config.test.js @@ -8,6 +8,7 @@ const path = require('node:path'); const { loadTs, ROOT } = require('./_load-ts'); const { + normalizeExplicitLibraryPath, prepareLibraryPathForPython, } = loadTs('src/main/library-path-config.ts'); @@ -58,19 +59,39 @@ test('an existing saved library stays config-owned and can change between scans' test('an explicit valid DLC_DIR remains an environment override', () => { const configDir = tmpConfigDir(); + const managedSongs = path.join(configDir, 'Managed Songs'); + fs.mkdirSync(managedSongs); const result = prepareLibraryPathForPython( configDir, 'C:\\Default Songs', - ' D:\\Managed Songs ', + ` ${managedSongs} `, ); assert.deepEqual(result, { status: 'explicit-override', - environmentDlcDir: 'D:\\Managed Songs', + environmentDlcDir: managedSongs, }); assert.equal(fs.existsSync(path.join(configDir, 'config.json')), false); }); +test('an explicit DLC_DIR rejects whitespace, files, and missing paths', () => { + const root = tmpConfigDir(); + const file = path.join(root, 'not-a-directory'); + fs.writeFileSync(file, 'x'); + + assert.equal(normalizeExplicitLibraryPath(' '), undefined); + assert.equal(normalizeExplicitLibraryPath(file), undefined); + assert.equal(normalizeExplicitLibraryPath(path.join(root, 'missing')), undefined); +}); + +test('an explicit DLC_DIR is trimmed before directory validation', () => { + const root = tmpConfigDir(); + const directory = path.join(root, 'Managed Songs'); + fs.mkdirSync(directory); + + assert.equal(normalizeExplicitLibraryPath(` ${directory} `), directory); +}); + test('a corrupt config is never overwritten during bootstrap', () => { const configDir = tmpConfigDir(); const configFile = path.join(configDir, 'config.json'); @@ -86,6 +107,8 @@ test('a corrupt config is never overwritten during bootstrap', () => { test('python startup does not pin its resolved fallback as DLC_DIR', () => { const source = fs.readFileSync(path.join(ROOT, 'src', 'main', 'python.ts'), 'utf8'); + assert.match(source, /normalizeExplicitLibraryPath\(process\.env\.DLC_DIR\)/); + assert.doesNotMatch(source, /existsSync\(process\.env\.DLC_DIR\)/); assert.match(source, /prepareLibraryPathForPython\(configDir, dlcDir, explicitDlcDir\)/); assert.doesNotMatch(source, /DLC_DIR:\s*dlcDir/); assert.match(source, /delete pythonEnv\.DLC_DIR/);