Skip to content

Commit c537c64

Browse files
committed
fix(core): address box review invariants
1 parent 107800a commit c537c64

16 files changed

Lines changed: 494 additions & 20 deletions

File tree

packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,32 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3030
return Either.right(parsed)
3131
}
3232

33+
/**
34+
* Parses a raw SSH port value into the valid Docker host-port range.
35+
*
36+
* @param value - Raw textual value for `--ssh-port`.
37+
* @returns Either a valid integer port or a typed parse error for `--ssh-port`.
38+
* @pure true
39+
* @effect none; CORE parser only evaluates the provided string.
40+
* @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535.
41+
* @precondition value is untrusted CLI or config text.
42+
* @postcondition the function returns a typed Either and never throws.
43+
* @complexity O(1) time / O(1) space.
44+
*/
3345
export const parseSshPort = (value: string): Either.Either<number, ParseError> => parsePort(value)
3446

47+
/**
48+
* Parses and validates the SSH user used by generated Dockerfiles and entrypoints.
49+
*
50+
* @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user.
51+
* @returns Either a Linux user name matching the docker-git invariant or a typed parse error.
52+
* @pure true
53+
* @effect none; CORE parser only trims and validates the candidate string.
54+
* @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$.
55+
* @precondition value is untrusted CLI or config text.
56+
* @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption.
57+
* @complexity O(n) time / O(1) space where n = |value|.
58+
*/
3559
export const parseSshUser = (
3660
value: string | undefined
3761
): Either.Either<string, ParseError> => {
@@ -52,6 +76,18 @@ export const parseSshUser = (
5276
return Either.right(candidate)
5377
}
5478

79+
/**
80+
* Parses the Docker network mode selector used by generated compose files.
81+
*
82+
* @param value - Optional raw value for `--network-mode`; undefined falls back to the template default.
83+
* @returns Either a supported network mode or a typed parse error for `--network-mode`.
84+
* @pure true
85+
* @effect none; CORE parser only trims and checks a finite domain.
86+
* @invariant Right(mode) implies mode is either "shared" or "project".
87+
* @precondition value is untrusted CLI or config text.
88+
* @postcondition unsupported modes fail as InvalidOption.
89+
* @complexity O(n) time / O(1) space where n = |value|.
90+
*/
5591
export const parseDockerNetworkMode = (
5692
value: string | undefined
5793
): Either.Either<CreateCommand["config"]["dockerNetworkMode"], ParseError> => {
@@ -66,6 +102,18 @@ export const parseDockerNetworkMode = (
66102
})
67103
}
68104

105+
/**
106+
* Parses the GPU mode selector used by generated compose files.
107+
*
108+
* @param value - Optional raw value for `--gpu`; undefined falls back to the template default.
109+
* @returns Either a supported GPU mode or a typed parse error for `--gpu`.
110+
* @pure true
111+
* @effect none; CORE parser only trims and checks a finite domain.
112+
* @invariant Right(mode) implies mode is either "none" or "all".
113+
* @precondition value is untrusted CLI or config text.
114+
* @postcondition unsupported modes fail as InvalidOption.
115+
* @complexity O(n) time / O(1) space where n = |value|.
116+
*/
69117
export const parseGpuMode = (
70118
value: string | undefined
71119
): Either.Either<CreateCommand["config"]["gpu"], ParseError> => {
@@ -80,6 +128,20 @@ export const parseGpuMode = (
80128
})
81129
}
82130

131+
/**
132+
* Parses a required non-empty string option with an optional fallback.
133+
*
134+
* @param option - CLI option name reported in typed parse errors.
135+
* @param value - Optional raw value supplied by the user.
136+
* @param fallback - Optional default used when value is undefined.
137+
* @returns Either the trimmed non-empty candidate or a typed missing-option error.
138+
* @pure true
139+
* @effect none; CORE parser only trims and checks string length.
140+
* @invariant Right(candidate) implies candidate.length > 0.
141+
* @precondition option names the boundary field being decoded.
142+
* @postcondition missing or empty candidates fail as MissingRequiredOption.
143+
* @complexity O(n) time / O(1) space where n = |value|.
144+
*/
83145
export const nonEmpty = (
84146
option: string,
85147
value: string | undefined,

packages/app/src/docker-git/frontend-lib/core/command-builders.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { Either } from "effect"
33

44
import { expandContainerHome } from "../usecases/scrap-path.js"
55
import { resolveAutoAgentFlags } from "./auto-agent-flags.js"
6-
import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js"
6+
import {
7+
nonEmpty,
8+
parseDockerNetworkMode,
9+
parseGpuMode,
10+
parseSshPort,
11+
parseSshUser
12+
} from "./command-builders-shared.js"
713
import { type RawOptions } from "./command-options.js"
814
import {
915
type AgentMode,

packages/app/src/lib/core/command-builders-shared.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,32 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3030
return Either.right(parsed)
3131
}
3232

33+
/**
34+
* Parses a raw SSH port value into the valid Docker host-port range.
35+
*
36+
* @param value - Raw textual value for `--ssh-port`.
37+
* @returns Either a valid integer port or a typed parse error for `--ssh-port`.
38+
* @pure true
39+
* @effect none; CORE parser only evaluates the provided string.
40+
* @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535.
41+
* @precondition value is untrusted CLI or config text.
42+
* @postcondition the function returns a typed Either and never throws.
43+
* @complexity O(1) time / O(1) space.
44+
*/
3345
export const parseSshPort = (value: string): Either.Either<number, ParseError> => parsePort(value)
3446

47+
/**
48+
* Parses and validates the SSH user used by generated Dockerfiles and entrypoints.
49+
*
50+
* @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user.
51+
* @returns Either a Linux user name matching the docker-git invariant or a typed parse error.
52+
* @pure true
53+
* @effect none; CORE parser only trims and validates the candidate string.
54+
* @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$.
55+
* @precondition value is untrusted CLI or config text.
56+
* @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption.
57+
* @complexity O(n) time / O(1) space where n = |value|.
58+
*/
3559
export const parseSshUser = (
3660
value: string | undefined
3761
): Either.Either<string, ParseError> => {
@@ -52,6 +76,18 @@ export const parseSshUser = (
5276
return Either.right(candidate)
5377
}
5478

79+
/**
80+
* Parses the Docker network mode selector used by generated compose files.
81+
*
82+
* @param value - Optional raw value for `--network-mode`; undefined falls back to the template default.
83+
* @returns Either a supported network mode or a typed parse error for `--network-mode`.
84+
* @pure true
85+
* @effect none; CORE parser only trims and checks a finite domain.
86+
* @invariant Right(mode) implies mode is either "shared" or "project".
87+
* @precondition value is untrusted CLI or config text.
88+
* @postcondition unsupported modes fail as InvalidOption.
89+
* @complexity O(n) time / O(1) space where n = |value|.
90+
*/
5591
export const parseDockerNetworkMode = (
5692
value: string | undefined
5793
): Either.Either<CreateCommand["config"]["dockerNetworkMode"], ParseError> => {
@@ -66,6 +102,18 @@ export const parseDockerNetworkMode = (
66102
})
67103
}
68104

105+
/**
106+
* Parses the GPU mode selector used by generated compose files.
107+
*
108+
* @param value - Optional raw value for `--gpu`; undefined falls back to the template default.
109+
* @returns Either a supported GPU mode or a typed parse error for `--gpu`.
110+
* @pure true
111+
* @effect none; CORE parser only trims and checks a finite domain.
112+
* @invariant Right(mode) implies mode is either "none" or "all".
113+
* @precondition value is untrusted CLI or config text.
114+
* @postcondition unsupported modes fail as InvalidOption.
115+
* @complexity O(n) time / O(1) space where n = |value|.
116+
*/
69117
export const parseGpuMode = (
70118
value: string | undefined
71119
): Either.Either<CreateCommand["config"]["gpu"], ParseError> => {
@@ -80,6 +128,20 @@ export const parseGpuMode = (
80128
})
81129
}
82130

131+
/**
132+
* Parses a required non-empty string option with an optional fallback.
133+
*
134+
* @param option - CLI option name reported in typed parse errors.
135+
* @param value - Optional raw value supplied by the user.
136+
* @param fallback - Optional default used when value is undefined.
137+
* @returns Either the trimmed non-empty candidate or a typed missing-option error.
138+
* @pure true
139+
* @effect none; CORE parser only trims and checks string length.
140+
* @invariant Right(candidate) implies candidate.length > 0.
141+
* @precondition option names the boundary field being decoded.
142+
* @postcondition missing or empty candidates fail as MissingRequiredOption.
143+
* @complexity O(n) time / O(1) space where n = |value|.
144+
*/
83145
export const nonEmpty = (
84146
option: string,
85147
value: string | undefined,

packages/app/src/lib/core/command-builders.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { Either } from "effect"
33

44
import { expandContainerHome } from "../usecases/scrap-path.js"
55
import { resolveAutoAgentFlags } from "./auto-agent-flags.js"
6-
import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js"
6+
import {
7+
nonEmpty,
8+
parseDockerNetworkMode,
9+
parseGpuMode,
10+
parseSshPort,
11+
parseSshUser
12+
} from "./command-builders-shared.js"
713
import { type RawOptions } from "./command-options.js"
814
import {
915
type AgentMode,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// CHANGE: centralize POSIX shell literal rendering for generated scripts
2+
// WHY: Dockerfile RUN and entrypoint fragments share the same shell injection boundary
3+
// QUOTE(ТЗ): n/a
4+
// REF: PR-281-coderabbit-targetDir-shell-escape
5+
// SOURCE: n/a
6+
// FORMAT THEOREM: forall s: shell_eval(shellSingleQuote(s)) = s
7+
// PURITY: CORE
8+
// INVARIANT: single quotes in the source value are represented by the POSIX '"'"' sequence
9+
// COMPLEXITY: O(n)/O(n) where n = |value|
10+
/**
11+
* Renders a POSIX single-quoted shell literal.
12+
*
13+
* @param value - Untrusted string that will be embedded into generated shell code.
14+
* @returns Shell literal that evaluates back to `value`.
15+
* @pure true
16+
* @effect none; CORE renderer only transforms a string.
17+
* @invariant returned literals never leave source single quotes unescaped.
18+
* @precondition the output is consumed by POSIX-compatible shell syntax.
19+
* @postcondition command substitution characters remain data, not executable syntax.
20+
* @complexity O(n) time / O(n) space where n = |value|.
21+
*/
22+
export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`

packages/app/src/lib/core/templates-entrypoint/base.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { TemplateConfig } from "../domain.js"
2+
import { shellSingleQuote } from "../shell-literals.js"
23
import { renderInputRc } from "../templates-prompt.js"
34

5+
const renderTargetDirDefault = (config: TemplateConfig): string =>
6+
`TARGET_DIR="\${TARGET_DIR:-}"
7+
if [[ -z "$TARGET_DIR" ]]; then
8+
TARGET_DIR=${shellSingleQuote(config.targetDir)}
9+
fi`
10+
411
export const renderEntrypointHeader = (config: TemplateConfig): string =>
512
`#!/usr/bin/env bash
613
set -euo pipefail
714
815
REPO_URL="\${REPO_URL:-}"
916
REPO_REF="\${REPO_REF:-}"
1017
FORK_REPO_URL="\${FORK_REPO_URL:-}"
11-
TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}"
18+
${renderTargetDirDefault(config)}
1219
if [[ "$TARGET_DIR" == "~" ]]; then
1320
TARGET_DIR="$HOME"
1421
elif [[ "$TARGET_DIR" == "~/"* ]]; then

packages/app/src/lib/core/templates/dockerfile.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import type { TemplateConfig } from "../domain.js"
2+
import { shellSingleQuote } from "../shell-literals.js"
23
import { renderDockerfilePrompt } from "../templates-prompt.js"
34
import { renderDockerfileGlab } from "./glab.js"
45
import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js"
56

67
// CHANGE: use the shared link-foundation JS box as the generated project base image
7-
// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS alias is public and keeps CI pull size bounded
8+
// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift
89
// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий"
910
// REF: issue-267
1011
// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes
11-
// FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE
12+
// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1
1213
// PURITY: CORE
1314
// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers
1415
// COMPLEXITY: O(1)/O(1)
15-
const dockerGitBaseImage = "konard/box-js:latest"
16+
const dockerGitBaseImage = "konard/box-js:2.1.1"
1617

1718
/**
1819
* Renders the base image, root user, apt mirror, core packages, and sudo prelude.
@@ -21,12 +22,15 @@ const dockerGitBaseImage = "konard/box-js:latest"
2122
* @pure true
2223
* @effect none; CORE template renderer only constructs a string.
2324
* @invariant the returned fragment starts from the configured shared JS box image.
25+
* @precondition docker-git generated entrypoint remains the container entrypoint.
26+
* @postcondition the fragment keeps root available for setup and runtime bootstrap.
2427
* @complexity O(1) time / O(1) space.
2528
*/
2629
const renderDockerfilePrelude = (): string =>
2730
`ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage}
2831
FROM \${DOCKER_GIT_BASE_IMAGE}
2932
33+
#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd
3034
USER root
3135
ARG UBUNTU_APT_MIRROR=
3236
ENV DEBIAN_FRONTEND=noninteractive
@@ -255,6 +259,7 @@ const renderDockerfileBun = (config: TemplateConfig): string =>
255259
* @effect none; CORE template renderer only constructs a string.
256260
* @invariant rendered HOME, PATH, WORKDIR, sudoers, and AllowUsers entries target config.sshUser.
257261
* @precondition config.sshUser satisfies the Linux user-name invariant.
262+
* @postcondition inherited box or ubuntu accounts resolve to config.sshUser when present.
258263
* @complexity O(1) time / O(1) space.
259264
*/
260265
const renderDockerfileUsers = (config: TemplateConfig): string =>
@@ -324,11 +329,13 @@ RUN set -eu; \
324329
docker-git-session-sync --help >/dev/null; \
325330
fi`
326331

327-
const renderDockerfileWorkspace = (config: TemplateConfig): string =>
328-
`# Workspace path (supports root-level dirs like /repo)
332+
const renderDockerfileWorkspace = (config: TemplateConfig): string => {
333+
const targetDirLiteral = shellSingleQuote(config.targetDir)
334+
335+
return `# Workspace path (supports root-level dirs like /repo)
329336
RUN set -eu; \
330337
HOME_DIR="/home/${config.sshUser}"; \
331-
TARGET_DIR="${config.targetDir}"; \
338+
TARGET_DIR=${targetDirLiteral}; \
332339
mkdir -p "$HOME_DIR" "$TARGET_DIR"; \
333340
chown 1000:1000 "$HOME_DIR"; \
334341
if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi
@@ -346,6 +353,7 @@ RUN sed -i 's/\\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
346353
347354
EXPOSE 22
348355
ENTRYPOINT ["/entrypoint.sh"]`
356+
}
349357

350358
export const renderDockerfile = (config: TemplateConfig): string =>
351359
[

0 commit comments

Comments
 (0)