diff --git a/src/harperLifecycle.ts b/src/harperLifecycle.ts index e88c911..23952bd 100644 --- a/src/harperLifecycle.ts +++ b/src/harperLifecycle.ts @@ -143,6 +143,28 @@ export interface StartHarperOptions { harperBinPath?: string; } +/** + * Build the environment for the spawned Harper child process. Exported for testing. + * + * `HOME`/`USERPROFILE` are applied LAST so the dataRootDir isolation always takes precedence over + * caller-supplied `env` (and over a spread of `process.env`, which contains HOME) — otherwise a caller could + * clobber it and re-expose the developer's real home. The isolation keeps Harper's global boot pointer + * (`$HOME/.harperdb/hdb_boot_properties.file`) and generated license keys inside the throwaway dataRootDir: + * cleaned up with it, never touching the real home, and isolated across concurrent suites. On first start + * Harper records its rootPath in that global boot file and — with an explicit `--ROOTPATH` — never overwrites + * it again; teardown removes only dataRootDir, so without this isolation the developer's real `~/.harperdb` + * would be left pointing at a since-deleted temp install, silently breaking the next `harper dev`/`harper run` + * anywhere on the machine. + */ +export function buildHarperChildEnv(dataRootDir: string, config: any, env?: any): Record { + return { + HARPER_SET_CONFIG: JSON.stringify(config), + ...env, + HOME: dataRootDir, + USERPROFILE: dataRootDir, + }; +} + export interface HarperContext { /** Absolute path to the Harper installation directory */ dataRootDir: string; @@ -552,12 +574,10 @@ export async function startHarper(ctx: HarperTestContext, options?: StartHarperO args.push(`--HTTP_SECUREPORT=${loopbackAddress}:${HTTPS_PORT}`); } - // HARPER_SET_CONFIG must be passed as an environment variable, not a CLI arg, - // because applyRuntimeEnvVarConfig reads from process.env.HARPER_SET_CONFIG - const harperEnv = { - HARPER_SET_CONFIG: JSON.stringify(config), - ...options?.env, - }; + // HARPER_SET_CONFIG must be passed as an environment variable, not a CLI arg, because + // applyRuntimeEnvVarConfig reads from process.env.HARPER_SET_CONFIG. buildHarperChildEnv also isolates the + // child's HOME into dataRootDir so Harper's global boot pointer never lands in the developer's real home. + const harperEnv = buildHarperChildEnv(dataRootDir, config, options?.env); const result = await runHarperCommand({ args, diff --git a/test/harperLifecycle.test.ts b/test/harperLifecycle.test.ts index 3dec57f..a710dcd 100644 --- a/test/harperLifecycle.test.ts +++ b/test/harperLifecycle.test.ts @@ -6,7 +6,13 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; -import { killHarper, runHarperCommand, HarperStartupError, type StartedHarperTestContext } from '../src/harperLifecycle.ts'; +import { + killHarper, + runHarperCommand, + HarperStartupError, + buildHarperChildEnv, + type StartedHarperTestContext, +} from '../src/harperLifecycle.ts'; // Standalone scripts used as a fake "Harper binary" (passed via harperBinPath) to drive // runHarperCommand's startup watchdog through specific timing scenarios without a real Harper. @@ -243,3 +249,24 @@ test('killHarper returns immediately for an already-exited process', async () => // than waiting out the grace, while leaving plenty of headroom for a contended-CI stall. ok(Date.now() - start < 1000, 'should not wait the grace period for an already-dead process'); }); + +// Regression guard: the dataRootDir HOME isolation must take precedence over any caller-supplied env (or a +// spread of process.env, which contains HOME). If a caller could clobber HOME/USERPROFILE, Harper's global +// boot pointer would land in the developer's real ~/.harperdb and outlive the throwaway instance. +test('buildHarperChildEnv: HOME/USERPROFILE isolation wins over caller-supplied env', () => { + const env = buildHarperChildEnv('/tmp/data-root', { logging: { level: 'debug' } }, { + HOME: '/should/not/win', + USERPROFILE: '/should/not/win', + CUSTOM_VAR: 'kept', + }); + strictEqual(env.HOME, '/tmp/data-root', 'isolated HOME must override caller env'); + strictEqual(env.USERPROFILE, '/tmp/data-root', 'isolated USERPROFILE must override caller env'); + strictEqual(env.CUSTOM_VAR, 'kept', 'non-isolation caller env is still passed through'); + strictEqual(env.HARPER_SET_CONFIG, JSON.stringify({ logging: { level: 'debug' } })); +}); + +test('buildHarperChildEnv: isolates HOME/USERPROFILE to dataRootDir by default', () => { + const env = buildHarperChildEnv('/tmp/data-root', {}); + strictEqual(env.HOME, '/tmp/data-root'); + strictEqual(env.USERPROFILE, '/tmp/data-root'); +});