Skip to content

Commit b1a1dd0

Browse files
committed
fix(shell): sanitize compose env files and gate docker-git build
1 parent 2da4a12 commit b1a1dd0

4 files changed

Lines changed: 222 additions & 0 deletions

File tree

.github/workflows/check.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
- uses: actions/checkout@v6
2424
- name: Install dependencies
2525
uses: ./.github/actions/setup
26+
- name: Build (docker-git package)
27+
run: pnpm --filter ./packages/app build
2628

2729
types:
2830
name: Types
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { PlatformError } from "@effect/platform/Error"
2+
import * as FileSystem from "@effect/platform/FileSystem"
3+
import * as Path from "@effect/platform/Path"
4+
import { Effect } from "effect"
5+
6+
import type { TemplateConfig } from "../core/domain.js"
7+
import { resolvePathFromBase } from "./auth-sync-helpers.js"
8+
import { sanitizeComposeEnvFile } from "./env-file.js"
9+
10+
const formatInvalidLineNumbers = (lineNumbers: ReadonlyArray<number>): string => lineNumbers.join(", ")
11+
12+
const sanitizeTemplateEnvPath = (
13+
fs: FileSystem.FileSystem,
14+
envPath: string
15+
): Effect.Effect<void, PlatformError> =>
16+
sanitizeComposeEnvFile(fs, envPath).pipe(
17+
Effect.flatMap((invalidLines) =>
18+
invalidLines.length === 0
19+
? Effect.void
20+
: Effect.logWarning(
21+
`Sanitized ${envPath} for docker compose by removing invalid lines: ${
22+
formatInvalidLineNumbers(invalidLines.map((entry) => entry.lineNumber))
23+
}.`
24+
)
25+
)
26+
)
27+
28+
// CHANGE: sanitize project env files before docker compose consumes them
29+
// WHY: docker compose rejects merge markers and shell-only syntax in env_file inputs
30+
// QUOTE(ТЗ): n/a
31+
// REF: user-request-2026-02-26-invalid-project-env
32+
// SOURCE: n/a
33+
// FORMAT THEOREM: ∀cfg: sanitize(cfg) → compose_safe(env_global(cfg)) ∧ compose_safe(env_project(cfg))
34+
// PURITY: SHELL
35+
// EFFECT: Effect<void, PlatformError, FileSystem | Path>
36+
// INVARIANT: only project env files are rewritten; missing files are ignored
37+
// COMPLEXITY: O(n) where n = |env_global| + |env_project|
38+
export const sanitizeTemplateComposeEnvFiles = (
39+
baseDir: string,
40+
template: Pick<TemplateConfig, "envGlobalPath" | "envProjectPath">
41+
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
42+
Effect.gen(function*(_) {
43+
const fs = yield* _(FileSystem.FileSystem)
44+
const path = yield* _(Path.Path)
45+
const globalEnvPath = resolvePathFromBase(path, baseDir, template.envGlobalPath)
46+
const projectEnvPath = resolvePathFromBase(path, baseDir, template.envProjectPath)
47+
48+
yield* _(sanitizeTemplateEnvPath(fs, globalEnvPath))
49+
yield* _(sanitizeTemplateEnvPath(fs, projectEnvPath))
50+
})

packages/lib/src/usecases/env-file.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ type EnvEntry = {
88
readonly value: string
99
}
1010

11+
export type InvalidComposeEnvLine = {
12+
readonly lineNumber: number
13+
readonly content: string
14+
}
15+
16+
export type ComposeEnvInspection = {
17+
readonly sanitized: string
18+
readonly invalidLines: ReadonlyArray<InvalidComposeEnvLine>
19+
}
20+
1121
const splitLines = (input: string): ReadonlyArray<string> =>
1222
input.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n")
1323

@@ -70,6 +80,19 @@ const parseEnvLine = (line: string): EnvEntry | null => {
7080
return { key, value }
7181
}
7282

83+
const inspectComposeEnvLine = (line: string): string | null => {
84+
const trimmed = line.trim()
85+
if (trimmed.length === 0) {
86+
return ""
87+
}
88+
if (trimmed.startsWith("#")) {
89+
return trimmed
90+
}
91+
92+
const parsed = parseEnvLine(line)
93+
return parsed ? `${parsed.key}=${parsed.value}` : null
94+
}
95+
7396
// CHANGE: parse env file contents into key/value entries
7497
// WHY: allow updating shared auth env deterministically
7598
// QUOTE(ТЗ): "система авторизации"
@@ -151,6 +174,73 @@ export const upsertEnvKey = (input: string, key: string, value: string): string
151174
// COMPLEXITY: O(n) where n = |lines|
152175
export const removeEnvKey = (input: string, key: string): string => upsertEnvKey(input, key, "")
153176

177+
// CHANGE: inspect compose env text and canonicalize supported assignments
178+
// WHY: docker compose env_file rejects merge markers and shell-only syntax
179+
// QUOTE(ТЗ): n/a
180+
// REF: user-request-2026-02-26-invalid-project-env
181+
// SOURCE: n/a
182+
// FORMAT THEOREM: ∀l ∈ lines(input): valid_env(l) ∨ comment(l) ∨ empty(l) → l ∈ sanitized(input)
183+
// PURITY: CORE
184+
// INVARIANT: invalid non-comment lines are removed and reported with 1-based line numbers
185+
// COMPLEXITY: O(n) where n = |lines|
186+
export const inspectComposeEnvText = (input: string): ComposeEnvInspection => {
187+
const sanitizedLines: Array<string> = []
188+
const invalidLines: Array<InvalidComposeEnvLine> = []
189+
const lines = splitLines(input)
190+
191+
for (const [index, line] of lines.entries()) {
192+
const sanitizedLine = inspectComposeEnvLine(line)
193+
194+
if (sanitizedLine === null) {
195+
invalidLines.push({
196+
lineNumber: index + 1,
197+
content: line
198+
})
199+
continue
200+
}
201+
202+
sanitizedLines.push(sanitizedLine)
203+
}
204+
205+
return {
206+
sanitized: normalizeEnvText(joinLines(sanitizedLines)),
207+
invalidLines
208+
}
209+
}
210+
211+
// CHANGE: sanitize compose env file contents in place
212+
// WHY: make docker compose env_file inputs deterministic and parseable
213+
// QUOTE(ТЗ): n/a
214+
// REF: user-request-2026-02-26-invalid-project-env
215+
// SOURCE: n/a
216+
// FORMAT THEOREM: ∀p: exists_file(p) → compose_safe(read(p)) after sanitize(p)
217+
// PURITY: SHELL
218+
// EFFECT: Effect<ReadonlyArray<InvalidComposeEnvLine>, PlatformError, FileSystem>
219+
// INVARIANT: missing or non-file paths are ignored
220+
// COMPLEXITY: O(n) where n = |file|
221+
export const sanitizeComposeEnvFile = (
222+
fs: FileSystem.FileSystem,
223+
envPath: string
224+
): Effect.Effect<ReadonlyArray<InvalidComposeEnvLine>, PlatformError> =>
225+
Effect.gen(function*(_) {
226+
const exists = yield* _(fs.exists(envPath))
227+
if (!exists) {
228+
return []
229+
}
230+
231+
const info = yield* _(fs.stat(envPath))
232+
if (info.type !== "File") {
233+
return []
234+
}
235+
236+
const current = yield* _(fs.readFileString(envPath))
237+
const inspected = inspectComposeEnvText(current)
238+
if (inspected.sanitized !== normalizeEnvText(current)) {
239+
yield* _(fs.writeFileString(envPath, inspected.sanitized))
240+
}
241+
return inspected.invalidLines
242+
})
243+
154244
export const defaultEnvContents = "# docker-git env\n# KEY=value\n"
155245

156246
// CHANGE: ensure env file exists
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as FileSystem from "@effect/platform/FileSystem"
2+
import * as Path from "@effect/platform/Path"
3+
import { NodeContext } from "@effect/platform-node"
4+
import { describe, expect, it } from "@effect/vitest"
5+
import { Effect } from "effect"
6+
7+
import { inspectComposeEnvText, sanitizeComposeEnvFile } from "../../src/usecases/env-file.js"
8+
9+
const withTempDir = <A, E, R>(
10+
use: (tempDir: string) => Effect.Effect<A, E, R>
11+
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
12+
Effect.scoped(
13+
Effect.gen(function*(_) {
14+
const fs = yield* _(FileSystem.FileSystem)
15+
const tempDir = yield* _(
16+
fs.makeTempDirectoryScoped({
17+
prefix: "docker-git-env-file-"
18+
})
19+
)
20+
return yield* _(use(tempDir))
21+
})
22+
)
23+
24+
describe("inspectComposeEnvText", () => {
25+
it("drops merge conflict markers and canonicalizes docker compose env assignments", () => {
26+
const input = [
27+
"# docker-git env",
28+
" export GITHUB_TOKEN = token-1 ",
29+
"<<<<<<< Updated upstream",
30+
"=======",
31+
">>>>>>> Stashed changes",
32+
" CODEX_SHARE_AUTH = 1 ",
33+
""
34+
].join("\n")
35+
36+
const inspected = inspectComposeEnvText(input)
37+
38+
expect(inspected.invalidLines.map((line) => line.lineNumber)).toEqual([3, 4, 5])
39+
expect(inspected.sanitized).toBe([
40+
"# docker-git env",
41+
"GITHUB_TOKEN=token-1",
42+
"CODEX_SHARE_AUTH=1",
43+
""
44+
].join("\n"))
45+
})
46+
47+
it.effect("sanitizes compose env files in place before docker compose reads them", () =>
48+
withTempDir((root) =>
49+
Effect.gen(function*(_) {
50+
const fs = yield* _(FileSystem.FileSystem)
51+
const path = yield* _(Path.Path)
52+
const envPath = path.join(root, "project.env")
53+
54+
yield* _(
55+
fs.writeFileString(
56+
envPath,
57+
[
58+
"# docker-git env",
59+
" export GITHUB_TOKEN = token-1 ",
60+
"<<<<<<< Updated upstream",
61+
"BAD LINE",
62+
" CODEX_SHARE_AUTH = 1 ",
63+
""
64+
].join("\n")
65+
)
66+
)
67+
68+
const invalidLines = yield* _(sanitizeComposeEnvFile(fs, envPath))
69+
const sanitized = yield* _(fs.readFileString(envPath))
70+
71+
expect(invalidLines.map((line) => line.lineNumber)).toEqual([3, 4])
72+
expect(sanitized).toBe([
73+
"# docker-git env",
74+
"GITHUB_TOKEN=token-1",
75+
"CODEX_SHARE_AUTH=1",
76+
""
77+
].join("\n"))
78+
})
79+
).pipe(Effect.provide(NodeContext.layer)))
80+
})

0 commit comments

Comments
 (0)