Skip to content
Open
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
5 changes: 5 additions & 0 deletions .bumpy/env-blob-version-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: minor
---

Serialized env blobs (__VARLOCK_ENV) now record the varlock version that produced them. Automatic blob reuse re-resolves when the producer version differs, and the runtime warns when env was resolved by a different varlock minor/major than the runtime code consuming it.
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ When the process was launched by [`varlock run`](/reference/cli/load-and-run/#ru
Reuse only happens when a fresh resolution would produce the same result:

- the blob was resolved in the same directory the app would resolve in (a root-level `varlock run` in a monorepo does not stop per-package resolution)
- it was produced by the same varlock version that is consuming it (a global CLI and a local package dependency at different versions re-resolve rather than trusting each other's format)
- it resolved without errors
- the `.env` files it was resolved from are unchanged on disk (editing an env file and restarting the app inside the same `varlock run` re-resolves and picks up the edit)
- no env override recorded in the blob has changed since (`varlock run -- sh -c 'FOO=x node app.js'` re-resolves, so the new `FOO` wins)
Expand Down
9 changes: 9 additions & 0 deletions packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { getErrorLocation } from './error-location';
import type { VarlockPlugin } from './plugins';
import { runWithResolutionContext, getResolutionContext } from './resolution-context';
import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info';
import { VARLOCK_VERSION } from '../../lib/varlock-version';
import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars';
import { isVarlockReservedKey } from './reserved-vars';
import { normalizeOverrideKeys } from '../../lib/injected-env-provenance';
Expand Down Expand Up @@ -59,6 +60,13 @@ export type DefinitionSourceEntry = {
};

export type SerializedEnvGraph = {
/**
* Version of the varlock package that produced this blob. Consumers can be at a
* different version (runtime glue bundled into an integration, a parent `varlock run`,
* a global CLI), so changes to this serialized format must stay backward compatible
* within a major. Absent on blobs from producers older than this field.
*/
varlockVersion?: string;
basePath?: string;
sources: Array<{
type: string;
Expand Down Expand Up @@ -917,6 +925,7 @@ export class EnvGraph {

getSerializedGraph(opts?: { includeInternal?: boolean, filterKeys?: Set<string> }): SerializedEnvGraph {
const serializedGraph: SerializedEnvGraph = {
varlockVersion: VARLOCK_VERSION,
basePath: this.basePath,
sources: [],
config: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import outdent from 'outdent';
import { EnvGraph } from '../index';
import { DotEnvFileDataSource } from '../lib/data-source';
import { VARLOCK_VERSION } from '../../lib/varlock-version';

async function loadSchema(contents: string, overrideValues?: Record<string, string>) {
const g = new EnvGraph();
Expand All @@ -12,6 +13,15 @@ async function loadSchema(contents: string, overrideValues?: Record<string, stri
return g;
}

describe('getSerializedGraph version stamp', () => {
it('records the producing varlock version so consumers can detect skew', async () => {
const g = await loadSchema('FOO=bar');
const blob = g.getSerializedGraph();
expect(blob.varlockVersion).toBe(VARLOCK_VERSION);
expect(VARLOCK_VERSION).toMatch(/^\d+\.\d+\.\d+/);
});
});

describe('getSerializedGraph filterKeys', () => {
it('excludes filtered-out items from config but keeps selected ones', async () => {
const g = await loadSchema(outdent`
Expand Down
16 changes: 15 additions & 1 deletion packages/varlock/src/lib/injected-env-reuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import type { SerializedEnvGraph } from '../env-graph';
import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto';
import { readVarlockPackageJsonConfig } from './package-json-config';
import { VARLOCK_VERSION } from './varlock-version';
import { envValueMatchesBlobItem } from './injected-env-provenance';
import { hashEnvSourceContents } from './env-source-fingerprint';

Expand Down Expand Up @@ -177,7 +178,9 @@ export function evaluateInjectedEnvReuse(opts: {
if (strippedInternalKeys.length) blobJson = JSON.stringify(parsedEnv);

// explicit trust - the sandbox path. The blob is authoritative regardless of where it
// was resolved; directory/drift checks make no sense for a blob from another machine.
// was resolved; directory/drift checks make no sense for a blob from another machine,
// and neither does the producer-version check below (there is nothing to re-resolve
// from). Version skew there is surfaced by the runtime instead (see initVarlockEnv).
if (mode === 'force') {
return {
reuse: true, parsedEnv, blobJson, strippedInternalKeys,
Expand All @@ -190,6 +193,17 @@ export function evaluateInjectedEnvReuse(opts: {
// surface a proper failure rather than booting the app on known-bad values
if (parsedEnv.errors) return { reuse: false, reason: 'blob contains resolution errors' };

// Producer version skew: the blob format is only guaranteed to match between identical
// versions (global CLI vs local package, or a parent `varlock run` from before an
// upgrade). Re-resolving through the current CLI is always correct, so don't risk it.
// Older producers didn't stamp a version, which is itself a mismatch.
if (parsedEnv.varlockVersion !== VARLOCK_VERSION) {
return {
reuse: false,
reason: `blob was produced by varlock ${parsedEnv.varlockVersion ?? '(unversioned)'}, current is ${VARLOCK_VERSION}`,
};
}

// older producers may not have recorded basePath - we can't verify locality, so re-resolve
if (!parsedEnv.basePath) return { reuse: false, reason: 'blob has no basePath recorded' };

Expand Down
26 changes: 26 additions & 0 deletions packages/varlock/src/lib/test/injected-env-reuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import os from 'node:os';
import { evaluateInjectedEnvReuse, USE_INJECTED_ENV_VAR } from '../injected-env-reuse';
import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../../runtime/crypto';
import { hashEnvSourceContents } from '../env-source-fingerprint';
import { VARLOCK_VERSION } from '../varlock-version';

let tempDir: string;

Expand All @@ -24,6 +25,7 @@ afterEach(() => {

function makeBlob(overrides?: Record<string, any>) {
return JSON.stringify({
varlockVersion: VARLOCK_VERSION,
basePath: tempDir,
sources: [],
settings: {},
Expand Down Expand Up @@ -89,6 +91,22 @@ describe('evaluateInjectedEnvReuse', () => {
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('errors') });
});

test('does not reuse a blob produced by a different varlock version', () => {
const decision = evaluateInjectedEnvReuse({
env: { __VARLOCK_ENV: makeBlob({ varlockVersion: '0.0.1' }) },
cwd: tempDir,
});
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('produced by varlock 0.0.1') });
});

test('does not reuse a blob from a producer that did not stamp a version', () => {
const decision = evaluateInjectedEnvReuse({
env: { __VARLOCK_ENV: makeBlob({ varlockVersion: undefined }) },
cwd: tempDir,
});
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('(unversioned)') });
});

test('does not reuse a blob with no basePath', () => {
const decision = evaluateInjectedEnvReuse({
env: { __VARLOCK_ENV: makeBlob({ basePath: undefined }) },
Expand Down Expand Up @@ -263,6 +281,14 @@ describe('evaluateInjectedEnvReuse', () => {
});
expect(decision.reuse).toBe(true);
});

test('forced mode ignores producer version skew (nothing to re-resolve from)', () => {
const decision = evaluateInjectedEnvReuse({
env: { __VARLOCK_ENV: makeBlob({ varlockVersion: '0.0.1' }), [USE_INJECTED_ENV_VAR]: '1' },
cwd: tempDir,
});
expect(decision.reuse).toBe(true);
});
});

describe('override drift', () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/varlock/src/lib/varlock-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import packageJson from '../../package.json';

/**
* Version of this varlock package, baked into builds at bundle time.
*
* Used to stamp serialized `__VARLOCK_ENV` blobs with their producer version and to detect
* skew on the consumer side. Producers and consumers can legitimately be different builds:
* a parent `varlock run` vs a child process's varlock dependency, a global CLI vs a local
* package, or runtime code bundled into an integration (e.g. the nextjs @next/env
* replacement) vs the installed varlock that resolved the env.
*/
export const VARLOCK_VERSION: string = packageJson.version;
26 changes: 26 additions & 0 deletions packages/varlock/src/runtime/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { redactString } from './lib/redaction';

import type { SerializedEnvGraph } from '../env-graph';
import { isBrowser } from '../lib/detect-runtime';
import { VARLOCK_VERSION } from '../lib/varlock-version';
import { debug } from './lib/debug';

// TODO: would like to move all of the redaction utils out of this file
Expand Down Expand Up @@ -496,6 +497,30 @@ export function getPreInjectionProcessEnv(): Record<string, string | undefined>
return getEnvState().originalProcessEnv;
}

/**
* Surface producer/consumer version skew on the env blob. This runtime code can be a
* different build than the varlock that resolved the env - e.g. runtime glue bundled into
* an integration package, or a parent `varlock run` from before an upgrade. The serialized
* format must stay backward compatible within a major, so patch skew is only debug-logged;
* a minor/major difference gets one loud warning per process (flag on globalThis since
* multiple module instances share the blob).
*/
function checkBlobVersionSkew(blobVersion: string | undefined) {
if (blobVersion === VARLOCK_VERSION) return;
debug(`env blob produced by varlock ${blobVersion ?? '(unversioned)'}, this runtime code is from ${VARLOCK_VERSION}`);
if (!blobVersion) return; // pre-stamp producer - nothing more specific to say
const [blobMajor, blobMinor] = blobVersion.split('.');
const [selfMajor, selfMinor] = VARLOCK_VERSION.split('.');
if (blobMajor === selfMajor && blobMinor === selfMinor) return;
if ((globalThis as any).__varlockVersionSkewWarned) return;
(globalThis as any).__varlockVersionSkewWarned = true;
// eslint-disable-next-line no-console
console.warn(
`[varlock] env was resolved by varlock ${blobVersion}, but this runtime code is from varlock ${VARLOCK_VERSION}.`
+ ' Update your varlock and integration packages together to keep versions aligned.',
);
}

export function initVarlockEnv(opts?: {
allowFail?: boolean,
}) {
Expand Down Expand Up @@ -550,6 +575,7 @@ export function initVarlockEnv(opts?: {
].join('\n'));
throw new Error('initVarlockEnv failed');
}
checkBlobVersionSkew(serializedEnvData.varlockVersion);
Object.assign(varlockSettings, serializedEnvData.settings);
envState.configHasErrors = !!(serializedEnvData as any).errors;
resetRedactionMap(serializedEnvData);
Expand Down
85 changes: 85 additions & 0 deletions packages/varlock/src/runtime/test/blob-version-skew.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
Tests for the producer/consumer version-skew warning on __VARLOCK_ENV blobs.
The runtime code parsing a blob can be a different varlock build than the one that
produced it (bundled integration glue, parent `varlock run` from before an upgrade).
Patch-level skew is expected and silent; minor/major skew warns once per process.
*/
import {
describe, it, expect, beforeEach, afterEach, vi,
} from 'vitest';
import { VARLOCK_VERSION } from '../../lib/varlock-version';

const ENV_STATE_KEY = '__varlockEnvState';
const REDACTION_STATE_KEY = '__varlockRedactionState';
const SKEW_WARNED_KEY = '__varlockVersionSkewWarned';

const [selfMajor, selfMinor, selfPatch] = VARLOCK_VERSION.split('.').map((p) => parseInt(p, 10));

function makeEnvBlob(varlockVersion: string | undefined) {
return JSON.stringify({
...varlockVersion !== undefined ? { varlockVersion } : {},
sources: [],
settings: {},
config: { BVS_FOO: { value: 'foo-val', isSensitive: false } },
});
}

async function importFreshEnvModuleCopy() {
vi.resetModules();
return import('../env');
}

function cleanup() {
delete (globalThis as any)[ENV_STATE_KEY];
delete (globalThis as any)[REDACTION_STATE_KEY];
delete (globalThis as any)[SKEW_WARNED_KEY];
delete process.env.__VARLOCK_ENV;
delete process.env.BVS_FOO;
vi.restoreAllMocks();
}

beforeEach(cleanup);
afterEach(cleanup);

async function initWithBlobVersion(varlockVersion: string | undefined) {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
process.env.__VARLOCK_ENV = makeEnvBlob(varlockVersion);
const envModule = await importFreshEnvModuleCopy();
envModule.initVarlockEnv();
return warnSpy;
}

describe('env blob version skew warning', () => {
it('matching version does not warn', async () => {
const warnSpy = await initWithBlobVersion(VARLOCK_VERSION);
expect(warnSpy).not.toHaveBeenCalled();
});

it('unstamped blob (older producer) does not warn', async () => {
const warnSpy = await initWithBlobVersion(undefined);
expect(warnSpy).not.toHaveBeenCalled();
});

it('patch-level skew does not warn', async () => {
const warnSpy = await initWithBlobVersion(`${selfMajor}.${selfMinor}.${selfPatch + 1}`);
expect(warnSpy).not.toHaveBeenCalled();
});

it('minor-level skew warns, but only once per process', async () => {
const skewedVersion = `${selfMajor}.${selfMinor + 1}.0`;
const warnSpy = await initWithBlobVersion(skewedVersion);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain(skewedVersion);
expect(warnSpy.mock.calls[0][0]).toContain(VARLOCK_VERSION);

// a second init (even via a fresh module copy, as bundlers create) stays quiet
const envModule = await importFreshEnvModuleCopy();
envModule.initVarlockEnv();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('major-level skew warns', async () => {
const warnSpy = await initWithBlobVersion(`${selfMajor + 1}.0.0`);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
Loading