Skip to content

Commit bcff353

Browse files
authored
Detect .env source file edits in injected-env blob reuse (#1029)
* Detect .env source file edits in injected-env blob reuse Record a content fingerprint for each file-based source into the serialized env graph, and re-verify it in the automatic reuse path so a blob captured before an env file edit falls back to CLI re-resolution. Explicit trust mode (_VARLOCK_USE_INJECTED_ENV=1) still skips the check since sandbox blobs have no local files. * Verify disabled sources in injected-env reuse too @disable lives in the source's own content, so an edit to a disabled file can re-enable it. An edit that leaves it disabled just costs one harmless re-resolution. * Correct the non-regular-file rationale in the reuse source check Fresh resolution does read FIFO env sources (1Password Environments), so the fallback is right because a pipe's content can't be verified without a side-effectful read, not because the loader would skip it.
1 parent bd6260d commit bcff353

7 files changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
varlock: patch
3+
---
4+
5+
Injected env blob reuse now detects .env source file edits: auto-load and varlock run re-resolve instead of serving stale values when a source file changed since the blob was created

packages/varlock-website/src/content/docs/integrations/javascript.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Reuse only happens when a fresh resolution would produce the same result:
7474

7575
- 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)
7676
- it resolved without errors
77+
- 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)
7778
- 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)
7879

7980
If any check fails, auto-load falls back to the CLI. Control it explicitly with [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env): `0` always re-resolves, `1` always trusts the blob, skipping the directory check. `varlock run` applies the same rules when it finds a blob, which covers non-Node workloads. Trust mode is how you hand an env into an environment with no `.env` files at all, like a remote sandbox; see the [E2B](/sandboxes/e2b/#passing-resolved-values) and [Fly.io](/sandboxes/flyio/#passing-resolved-values) guides.

packages/varlock/src/env-graph/lib/env-graph.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type ProxyApprovalEach, type ProxyEgressMode, type ProxyManagedItem, type ProxyRule,
3939
} from '../../proxy/types';
4040
import { parseDuration } from '../../lib/duration';
41+
import { hashEnvSourceContents } from '../../lib/env-source-fingerprint';
4142

4243
const processExists = !!globalThis.process;
4344
const originalProcessEnv = { ...processExists && process.env };
@@ -64,6 +65,13 @@ export type SerializedEnvGraph = {
6465
label: string;
6566
enabled: boolean;
6667
path?: string;
68+
/**
69+
* Fingerprint of the file contents this resolution actually parsed (see
70+
* `hashEnvSourceContents`). The automatic injected-env reuse path re-hashes the file on
71+
* disk and re-resolves on mismatch, so a blob captured before an env file edit is never
72+
* served after it. Only present for file-based sources.
73+
*/
74+
contentHash?: string;
6775
}>,
6876
settings: {
6977
redactLogs?: boolean;
@@ -917,6 +925,10 @@ export class EnvGraph {
917925
label: source.label,
918926
enabled: !source.disabled,
919927
path: source instanceof FileBasedDataSource ? path.relative(this.basePath ?? '', source.fullPath) : undefined,
928+
// fingerprint what was actually parsed (not a re-read from disk, which could
929+
// already have changed) so injected-env reuse can detect later file edits
930+
...(source instanceof FileBasedDataSource && source.rawContents !== undefined)
931+
? { contentHash: hashEnvSourceContents(source.rawContents) } : {},
920932
});
921933
}
922934
for (const itemKey of this.sortedConfigKeys) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, it, expect } from 'vitest';
2+
import outdent from 'outdent';
3+
import { EnvGraph } from '../index';
4+
import { DotEnvFileDataSource } from '../lib/data-source';
5+
import { hashEnvSourceContents } from '../../lib/env-source-fingerprint';
6+
7+
describe('getSerializedGraph source fingerprints', () => {
8+
it('records a contentHash of the parsed contents for file-based sources', async () => {
9+
const contents = outdent`
10+
FOO=bar
11+
`;
12+
const g = new EnvGraph();
13+
await g.setRootDataSource(new DotEnvFileDataSource('.env.schema', { overrideContents: contents }));
14+
await g.finishLoad();
15+
await g.resolveEnvValues();
16+
17+
const blob = g.getSerializedGraph();
18+
expect(blob.sources).toHaveLength(1);
19+
expect(blob.sources[0].contentHash).toBe(hashEnvSourceContents(contents));
20+
});
21+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createHash } from 'node:crypto';
2+
3+
/**
4+
* Content fingerprint for a file-based env source, recorded into the serialized graph
5+
* (`SerializedEnvGraph.sources[].contentHash`) at resolution time and re-checked by the
6+
* automatic injected-env reuse path to detect source file edits since the blob was made.
7+
*
8+
* Truncated sha256 - this is drift detection, not an integrity/security boundary, and the
9+
* blob travels in an env var so we keep it short.
10+
*/
11+
export function hashEnvSourceContents(contents: string): string {
12+
return createHash('sha256').update(contents, 'utf8').digest('hex').slice(0, 16);
13+
}

packages/varlock/src/lib/injected-env-reuse.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SerializedEnvGraph } from '../env-graph';
44
import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto';
55
import { readVarlockPackageJsonConfig } from './package-json-config';
66
import { envValueMatchesBlobItem } from './injected-env-provenance';
7+
import { hashEnvSourceContents } from './env-source-fingerprint';
78

89
/**
910
* Decides whether a consumer (`varlock/auto-load`, or a `varlock run` that finds a blob
@@ -200,6 +201,43 @@ export function evaluateInjectedEnvReuse(opts: {
200201
};
201202
}
202203

204+
// Source drift: the producer fingerprints the contents of each file source it actually
205+
// parsed (see getSerializedGraph). If a source has been edited or removed since the blob
206+
// was made (e.g. an env file edit followed by a dev-server restart inside the same
207+
// `varlock run`), reuse could serve pre-edit values - re-resolve instead. Disabled
208+
// sources are verified too: `@disable` lives in the source's own content, so an edit can
209+
// re-enable it (an edit that leaves it disabled just costs one harmless re-resolution).
210+
// Known gap: a matching env file *created* after the blob won't appear in its sources
211+
// list and isn't detected here.
212+
if (!Array.isArray(parsedEnv.sources)) {
213+
return { reuse: false, reason: 'blob has no sources recorded' };
214+
}
215+
for (const source of parsedEnv.sources) {
216+
if (source.path === undefined) continue;
217+
// older producers didn't record fingerprints - we can't verify, so re-resolve
218+
if (!source.contentHash) {
219+
return { reuse: false, reason: `blob has no content fingerprint for source ${source.path}` };
220+
}
221+
const sourceFullPath = path.resolve(parsedEnv.basePath, source.path);
222+
let currentContents: string;
223+
try {
224+
// stat-gate before reading - env sources can legitimately be FIFOs (e.g. 1Password
225+
// Environments serves .env files as pipes), and reading one here would have side
226+
// effects (the serving process rewrites it) or block forever on a writerless pipe.
227+
// A non-regular file's content can't be verified without reading it, so fall back
228+
// to a fresh resolution, which reads it once the same way any normal load does.
229+
if (!fs.statSync(sourceFullPath).isFile()) {
230+
return { reuse: false, reason: `source ${source.path} is not a regular file` };
231+
}
232+
currentContents = fs.readFileSync(sourceFullPath, 'utf8');
233+
} catch {
234+
return { reuse: false, reason: `source file ${source.path} is missing or unreadable` };
235+
}
236+
if (hashEnvSourceContents(currentContents) !== source.contentHash) {
237+
return { reuse: false, reason: `source file ${source.path} changed since the blob was created` };
238+
}
239+
}
240+
203241
// Env drift: if ANY blob config key's ambient value differs from what the parent
204242
// injected (e.g. `varlock run -- sh -c 'FOO=x node app.js'`, whether or not FOO was
205243
// already an override at the parent), reusing the blob would clobber FOO back to the

packages/varlock/src/lib/test/injected-env-reuse.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'node:path';
66
import os from 'node:os';
77
import { evaluateInjectedEnvReuse, USE_INJECTED_ENV_VAR } from '../injected-env-reuse';
88
import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../../runtime/crypto';
9+
import { hashEnvSourceContents } from '../env-source-fingerprint';
910

1011
let tempDir: string;
1112

@@ -157,6 +158,113 @@ describe('evaluateInjectedEnvReuse', () => {
157158
});
158159
});
159160

161+
describe('source file drift', () => {
162+
// writes a real env file into tempDir and returns the blob source entry describing it
163+
function writeSourceFile(fileName: string, contents: string) {
164+
fs.writeFileSync(path.join(tempDir, fileName), contents);
165+
return {
166+
type: 'dotenv', label: fileName, enabled: true, path: fileName, contentHash: hashEnvSourceContents(contents),
167+
};
168+
}
169+
170+
test('reuses when all enabled source files are unchanged', () => {
171+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
172+
const decision = evaluateInjectedEnvReuse({
173+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
174+
cwd: tempDir,
175+
});
176+
expect(decision.reuse).toBe(true);
177+
});
178+
179+
test('does not reuse when a source file was edited after the blob was created', () => {
180+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
181+
fs.writeFileSync(path.join(tempDir, '.env'), 'FOO=edited-val\n');
182+
const decision = evaluateInjectedEnvReuse({
183+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
184+
cwd: tempDir,
185+
});
186+
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('changed since') });
187+
});
188+
189+
test('does not reuse when a source file is missing', () => {
190+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
191+
fs.unlinkSync(path.join(tempDir, '.env'));
192+
const decision = evaluateInjectedEnvReuse({
193+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
194+
cwd: tempDir,
195+
});
196+
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('missing') });
197+
});
198+
199+
test('does not reuse when a source path is no longer a regular file', () => {
200+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
201+
fs.unlinkSync(path.join(tempDir, '.env'));
202+
fs.mkdirSync(path.join(tempDir, '.env'));
203+
const decision = evaluateInjectedEnvReuse({
204+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
205+
cwd: tempDir,
206+
});
207+
expect(decision.reuse).toBe(false);
208+
});
209+
210+
test('does not reuse when an enabled file source has no fingerprint (older producer)', () => {
211+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
212+
delete (source as any).contentHash;
213+
const decision = evaluateInjectedEnvReuse({
214+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
215+
cwd: tempDir,
216+
});
217+
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('fingerprint') });
218+
});
219+
220+
test('an unchanged disabled source does not block reuse', () => {
221+
const source = writeSourceFile('.env.production', '# @disable\nFOO=prod-val\n');
222+
source.enabled = false;
223+
const decision = evaluateInjectedEnvReuse({
224+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
225+
cwd: tempDir,
226+
});
227+
expect(decision.reuse).toBe(true);
228+
});
229+
230+
test('a changed disabled source blocks reuse (editing can remove @disable)', () => {
231+
const source = writeSourceFile('.env.production', '# @disable\nFOO=prod-val\n');
232+
source.enabled = false;
233+
fs.writeFileSync(path.join(tempDir, '.env.production'), 'FOO=prod-val\n');
234+
const decision = evaluateInjectedEnvReuse({
235+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }) },
236+
cwd: tempDir,
237+
});
238+
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('changed since') });
239+
});
240+
241+
test('non-file sources (no path) are skipped', () => {
242+
const decision = evaluateInjectedEnvReuse({
243+
env: { __VARLOCK_ENV: makeBlob({ sources: [{ type: 'processEnv', label: 'process env', enabled: true }] }) },
244+
cwd: tempDir,
245+
});
246+
expect(decision.reuse).toBe(true);
247+
});
248+
249+
test('does not reuse a blob with no sources array recorded', () => {
250+
const decision = evaluateInjectedEnvReuse({
251+
env: { __VARLOCK_ENV: makeBlob({ sources: undefined }) },
252+
cwd: tempDir,
253+
});
254+
expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('sources') });
255+
});
256+
257+
test('forced mode ignores source drift (sandbox blobs have no local files)', () => {
258+
const source = writeSourceFile('.env', 'FOO=foo-val\n');
259+
fs.writeFileSync(path.join(tempDir, '.env'), 'FOO=edited-val\n');
260+
const decision = evaluateInjectedEnvReuse({
261+
env: { __VARLOCK_ENV: makeBlob({ sources: [source] }), [USE_INJECTED_ENV_VAR]: '1' },
262+
cwd: tempDir,
263+
});
264+
expect(decision.reuse).toBe(true);
265+
});
266+
});
267+
160268
describe('override drift', () => {
161269
test('reuses when override values still match', () => {
162270
const decision = evaluateInjectedEnvReuse({

0 commit comments

Comments
 (0)