diff --git a/src/lib/server/compose-validate/effective-compose.ts b/src/lib/server/compose-validate/effective-compose.ts index ed40145..de79a68 100644 --- a/src/lib/server/compose-validate/effective-compose.ts +++ b/src/lib/server/compose-validate/effective-compose.ts @@ -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, diff --git a/src/lib/server/compose-validate/env-file-paths.ts b/src/lib/server/compose-validate/env-file-paths.ts new file mode 100644 index 0000000..45b9e3c --- /dev/null +++ b/src/lib/server/compose-validate/env-file-paths.ts @@ -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 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(); + for (const svc of Object.values(services as Record)) { + 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); +} diff --git a/tests/compose-validate/env-file-paths.test.ts b/tests/compose-validate/env-file-paths.test.ts new file mode 100644 index 0000000..6be6306 --- /dev/null +++ b/tests/compose-validate/env-file-paths.test.ts @@ -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([]); + }); +});