compose validate no longer flags a valid env_file

This commit is contained in:
Jarek Krochmalski committed 2026-09-02 15:52:44 +02:00
1 parent 2d12085ae4
commit 9a3c52374c
3 files changed
+129 -2

No files matched your search

@@ -19,10 +19,12 @@
*/
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { resolveComposeDockerHost, buildComposeBaseArgs } from '../compose-docker-args';
import { parseCompose } from './parse';
import { extractMaterializableEnvFiles } from './env-file-paths';
import type { Finding } from './types';
const CONFIG_TIMEOUT_MS = 20_000;
@@ -58,6 +60,22 @@ export async function renderEffectiveCompose(
const file = join(dir, 'docker-compose.yml');
await writeFile(file, composeContent, 'utf8');
// Pre-create referenced env_file(s) as empty files so `docker compose config` resolves
// them. The user's real env file lives in their stack dir, not this scratch dir, so
// without this a valid `env_file: - .env` is falsely reported "env file ... not found".
// Only temp-dir-safe relative paths are materialized; interpolation values still come
// from envVars (passed as process env), so empty files don't skew other findings.
try {
const parsed = parseCompose(composeContent);
for (const rel of extractMaterializableEnvFiles(parsed.doc)) {
const target = join(dir, rel);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, '', { flag: 'wx' }).catch(() => undefined); // don't overwrite
}
} catch {
// A compose that won't parse is handled by the local parser; config will report it too.
}
const composeDockerHost = resolveComposeDockerHost(dockerHost, undefined);
const base = buildComposeBaseArgs(stackName, composeDockerHost); // ['docker', ('-H' host)?, 'compose', '-p', name]
// `--format json` renders the FULLY effective compose as JSON: interpolation,
@@ -0,0 +1,55 @@
/**
* Extract the `env_file` paths a compose references, keeping only those safe to MATERIALIZE
* inside a scratch validation dir. `docker compose config` fails "env file <path> not found"
* when a referenced env_file is absent from the cwd it runs in - but Compose Validate runs in a
* throwaway temp dir that has only the compose file, so a perfectly valid `env_file: - .env` is
* falsely rejected. We pre-create these files (empty) so config resolves.
*
* We only return paths that stay INSIDE the temp dir: a relative path with no `..` segment and
* not absolute. Absolute paths and `../` escapes are left alone (we can't/shouldn't write them;
* config's error for those is arguably legitimate).
*/
/** Normalize the compose `env_file` value (string | string[] | {path,required?}[]) to paths. */
function envFileEntriesToPaths(value: unknown): string[] {
const out: string[] = [];
const push = (v: unknown) => {
if (typeof v === 'string') {
if (v.trim()) out.push(v.trim());
} else if (v && typeof v === 'object' && typeof (v as { path?: unknown }).path === 'string') {
const p = (v as { path: string }).path.trim();
if (p) out.push(p);
}
};
if (typeof value === 'string') push(value);
else if (Array.isArray(value)) value.forEach(push);
return out;
}
/** True when `p` is a relative path that stays inside its base dir (no absolute, no `..`). */
function isInsideRelative(p: string): boolean {
if (!p || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return false; // absolute (posix / windows)
const segs = p.split(/[\\/]/);
return !segs.includes('..');
}
/**
* Every distinct, temp-dir-safe env_file path referenced by any service in the parsed compose.
* `doc` is the plain object from parseCompose. Returns relative paths (deduped) to create.
*/
export function extractMaterializableEnvFiles(doc: unknown): string[] {
const services = (doc as { services?: unknown })?.services;
if (!services || typeof services !== 'object') return [];
const paths = new Set<string>();
for (const svc of Object.values(services as Record<string, unknown>)) {
if (!svc || typeof svc !== 'object') continue;
const ef = (svc as { env_file?: unknown }).env_file;
if (ef === undefined) continue;
for (const p of envFileEntriesToPaths(ef)) {
if (isInsideRelative(p)) paths.add(p);
}
}
// Cap materialization: a validate request should never create an unbounded number of
// scratch files. Well past any real compose; the pre-parse already bounds payload size.
return [...paths].slice(0, 100);
}
@@ -0,0 +1,54 @@
import { describe, test, expect } from 'bun:test';
import { extractMaterializableEnvFiles } from '../../src/lib/server/compose-validate/env-file-paths';
// Compose Validate runs `docker compose config` in a scratch temp dir. A referenced env_file
// that lives in the user's stack dir is absent there, so config falsely reports "not found".
// extractMaterializableEnvFiles finds the temp-dir-safe paths to pre-create as empty files.
describe('extractMaterializableEnvFiles', () => {
test('list form: env_file: [.env]', () => {
const doc = { services: { web: { env_file: ['.env'] } } };
expect(extractMaterializableEnvFiles(doc)).toEqual(['.env']);
});
test('string form: env_file: prod.env', () => {
const doc = { services: { web: { env_file: 'prod.env' } } };
expect(extractMaterializableEnvFiles(doc)).toEqual(['prod.env']);
});
test('map form: env_file: [{path, required}]', () => {
const doc = { services: { web: { env_file: [{ path: 'a.env', required: false }, { path: 'b.env' }] } } };
expect(extractMaterializableEnvFiles(doc).sort()).toEqual(['a.env', 'b.env']);
});
test('nested relative path (config/.env) is kept', () => {
const doc = { services: { web: { env_file: ['config/.env'] } } };
expect(extractMaterializableEnvFiles(doc)).toEqual(['config/.env']);
});
test('dedupes across services', () => {
const doc = { services: { a: { env_file: '.env' }, b: { env_file: ['.env', 'x.env'] } } };
expect(extractMaterializableEnvFiles(doc).sort()).toEqual(['.env', 'x.env']);
});
test('absolute paths are EXCLUDED (cannot / must not materialize)', () => {
const doc = { services: { web: { env_file: ['/etc/app.env', 'C:\\app.env'] } } };
expect(extractMaterializableEnvFiles(doc)).toEqual([]);
});
test('parent-escaping paths are EXCLUDED', () => {
const doc = { services: { web: { env_file: ['../secret.env', 'a/../../b.env'] } } };
expect(extractMaterializableEnvFiles(doc)).toEqual([]);
});
test('no services / no env_file -> empty', () => {
expect(extractMaterializableEnvFiles({})).toEqual([]);
expect(extractMaterializableEnvFiles({ services: { web: { image: 'nginx' } } })).toEqual([]);
expect(extractMaterializableEnvFiles(null)).toEqual([]);
});
test('blank / non-string entries are ignored', () => {
const doc = { services: { web: { env_file: ['', ' ', 42, { required: true }] } } };
expect(extractMaterializableEnvFiles(doc as any)).toEqual([]);
});
});