diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts index 422d1e5..30a15be 100644 --- a/src/main/library-path-config.ts +++ b/src/main/library-path-config.ts @@ -19,10 +19,10 @@ function isPlainObject(value: unknown): value is Record { } /** - * Normalize an administrator-supplied DLC_DIR and accept it only when it - * already names a directory. Invalid overrides must not shadow config.json. + * Normalize a configured library path and accept it only when it already + * names a directory. */ -export function normalizeExplicitLibraryPath(rawPath?: string): string | undefined { +export function normalizeExistingLibraryDirectory(rawPath?: string): string | undefined { const candidate = (rawPath || '').trim(); if (!candidate) return undefined; @@ -33,6 +33,22 @@ export function normalizeExplicitLibraryPath(rawPath?: string): string | undefin } } +/** + * Apply the prepared library-path contract to the Python child environment. + * Starting from a copy of process.env means an invalid or stale parent value + * must be removed explicitly when config.json owns the path. + */ +export function applyLibraryPathToPythonEnvironment( + environment: Record, + preparation: LibraryPathPreparation, +): void { + if (preparation.environmentDlcDir) { + environment.DLC_DIR = preparation.environmentDlcDir; + } else { + delete environment.DLC_DIR; + } +} + /** * Prepare the library path contract for the Python backend. * @@ -46,7 +62,7 @@ export function prepareLibraryPathForPython( resolvedDlcDir: string, explicitDlcDir?: string, ): LibraryPathPreparation { - const override = normalizeExplicitLibraryPath(explicitDlcDir); + const override = normalizeExistingLibraryDirectory(explicitDlcDir); if (override) { return { status: 'explicit-override', @@ -76,7 +92,13 @@ export function prepareLibraryPathForPython( const configured = config.dlc_dir; if (typeof configured === 'string' && configured.trim()) { - return { status: 'configured' }; + if (normalizeExistingLibraryDirectory(configured)) { + return { status: 'configured' }; + } + return { + status: 'invalid-config', + error: 'config.json dlc_dir is not an existing directory', + }; } if (configured !== undefined && configured !== null && configured !== '') { return { diff --git a/src/main/python.ts b/src/main/python.ts index a3eee35..3733afa 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -11,7 +11,11 @@ import * as net from 'net'; import * as os from 'os'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { isDebugEnabled } from './debug-log'; -import { normalizeExplicitLibraryPath, prepareLibraryPathForPython } from './library-path-config'; +import { + applyLibraryPathToPythonEnvironment, + normalizeExistingLibraryDirectory, + prepareLibraryPathForPython, +} from './library-path-config'; let pythonProcess: ChildProcess | null = null; // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL @@ -424,7 +428,7 @@ function getPluginsDir(): string { return pluginsDir; } -function getDLCDir(explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR)): string { +function getDLCDir(explicitDlcDir = normalizeExistingLibraryDirectory(process.env.DLC_DIR)): string { if (explicitDlcDir) return explicitDlcDir; // Read from shared config @@ -497,7 +501,7 @@ export async function startPython(): Promise { } serverPort = await findPort(PREFERRED_PORT); const configDir = getConfigDir(); - const explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR); + const explicitDlcDir = normalizeExistingLibraryDirectory(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() @@ -582,13 +586,9 @@ export async function startPython(): Promise { : path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter ) + (process.env.PATH || ''), }; - if (libraryPath.environmentDlcDir) { - pythonEnv.DLC_DIR = libraryPath.environmentDlcDir; - } else { - // `...process.env` may carry an empty/invalid value. Do not let it - // shadow config.json in the normal dynamic-settings path. - delete pythonEnv.DLC_DIR; - } + // `...process.env` may carry an empty/invalid value. Do not let it shadow + // config.json in the normal dynamic-settings path. + applyLibraryPathToPythonEnvironment(pythonEnv, libraryPath); // Debug mode: raise the Slopsmith server's log level and tee its // structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js index 6c786fb..fa9981b 100644 --- a/tests/library-path-config.test.js +++ b/tests/library-path-config.test.js @@ -5,10 +5,11 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { loadTs, ROOT } = require('./_load-ts'); +const { loadTs } = require('./_load-ts'); const { - normalizeExplicitLibraryPath, + applyLibraryPathToPythonEnvironment, + normalizeExistingLibraryDirectory, prepareLibraryPathForPython, } = loadTs('src/main/library-path-config.ts'); @@ -45,18 +46,47 @@ test('bootstrap merges the fallback into an existing config without losing setti test('an existing saved library stays config-owned and can change between scans', () => { const configDir = tmpConfigDir(); const configFile = path.join(configDir, 'config.json'); - fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: 'D:\\Saved Songs' })); + const savedSongs = path.join(configDir, 'Saved Songs'); + fs.mkdirSync(savedSongs); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: savedSongs })); const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); assert.deepEqual(result, { status: 'configured' }); assert.deepEqual( JSON.parse(fs.readFileSync(configFile, 'utf8')), - { dlc_dir: 'D:\\Saved Songs' }, + { dlc_dir: savedSongs }, ); assert.equal(result.environmentDlcDir, undefined); }); +test('a missing saved library is reported without overwriting the intended path', () => { + const configDir = tmpConfigDir(); + const configFile = path.join(configDir, 'config.json'); + const missingSongs = path.join(configDir, 'Disconnected Songs'); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: missingSongs })); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'invalid-config'); + assert.match(result.error, /not an existing directory/); + assert.deepEqual(JSON.parse(fs.readFileSync(configFile, 'utf8')), { dlc_dir: missingSongs }); +}); + +test('a saved library file is rejected without overwriting the configured value', () => { + const configDir = tmpConfigDir(); + const configFile = path.join(configDir, 'config.json'); + const file = path.join(configDir, 'not-a-library'); + fs.writeFileSync(file, 'x'); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: file })); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'invalid-config'); + assert.match(result.error, /not an existing directory/); + assert.deepEqual(JSON.parse(fs.readFileSync(configFile, 'utf8')), { dlc_dir: file }); +}); + test('an explicit valid DLC_DIR remains an environment override', () => { const configDir = tmpConfigDir(); const managedSongs = path.join(configDir, 'Managed Songs'); @@ -79,9 +109,9 @@ test('an explicit DLC_DIR rejects whitespace, files, and missing paths', () => { 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); + assert.equal(normalizeExistingLibraryDirectory(' '), undefined); + assert.equal(normalizeExistingLibraryDirectory(file), undefined); + assert.equal(normalizeExistingLibraryDirectory(path.join(root, 'missing')), undefined); }); test('an explicit DLC_DIR is trimmed before directory validation', () => { @@ -89,7 +119,7 @@ test('an explicit DLC_DIR is trimmed before directory validation', () => { const directory = path.join(root, 'Managed Songs'); fs.mkdirSync(directory); - assert.equal(normalizeExplicitLibraryPath(` ${directory} `), directory); + assert.equal(normalizeExistingLibraryDirectory(` ${directory} `), directory); }); test('a corrupt config is never overwritten during bootstrap', () => { @@ -104,12 +134,40 @@ test('a corrupt config is never overwritten during bootstrap', () => { assert.equal(fs.readFileSync(configFile, 'utf8'), '{broken'); }); -test('python startup does not pin its resolved fallback as DLC_DIR', () => { - const source = fs.readFileSync(path.join(ROOT, 'src', 'main', 'python.ts'), 'utf8'); +test('Python environment omits DLC_DIR when config owns the library path', () => { + const environment = { DLC_DIR: 'C:\\Stale Parent Value', KEEP: 'yes' }; - 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/); + applyLibraryPathToPythonEnvironment(environment, { status: 'configured' }); + + assert.deepEqual(environment, { KEEP: 'yes' }); +}); + +test('Python environment exports only a validated explicit DLC_DIR', () => { + const configDir = tmpConfigDir(); + const managedSongs = path.join(configDir, 'Managed Songs'); + fs.mkdirSync(managedSongs); + const preparation = prepareLibraryPathForPython( + configDir, + 'C:\\Default Songs', + ` ${managedSongs} `, + ); + const environment = { DLC_DIR: 'C:\\Stale Parent Value', KEEP: 'yes' }; + + applyLibraryPathToPythonEnvironment(environment, preparation); + + assert.deepEqual(environment, { DLC_DIR: managedSongs, KEEP: 'yes' }); +}); + +test('Python environment removes an invalid explicit DLC_DIR', () => { + const configDir = tmpConfigDir(); + const fallback = path.join(configDir, 'Fallback Songs'); + fs.mkdirSync(fallback); + const file = path.join(configDir, 'not-a-directory'); + fs.writeFileSync(file, 'x'); + const preparation = prepareLibraryPathForPython(configDir, fallback, file); + const environment = { DLC_DIR: file }; + + applyLibraryPathToPythonEnvironment(environment, preparation); + + assert.deepEqual(environment, {}); });