Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 26 additions & 6 deletions src/harperLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> {
return {
HARPER_SET_CONFIG: JSON.stringify(config),
...env,
HOME: dataRootDir,
USERPROFILE: dataRootDir,
};
}

export interface HarperContext {
/** Absolute path to the Harper installation directory */
dataRootDir: string;
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion test/harperLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
});