Skip to content

Commit 097dcc2

Browse files
committed
fix(artifacts): env dual-mint id remap, backslash escapes, legacy ENV fidelity
Three review findings on the deployment-env config-key extraction: - yaml env-dual-mint ids now use an internal "env." prefix (same id_key remap the Dockerfile ARG path already used), so a top-level yaml key sharing a name with a recognized compose/k8s env var no longer collides on id with the plain yaml mint of that same top-level key. - _split_ws_respecting_quotes gained backslash-escape awareness: an unquoted `\ ` keeps a literal space instead of splitting the token, `\\` collapses to one literal backslash -- fixes silent truncation on Docker's own documented ENV example (`MY_DOG=Rex\ The\ Dog`). - The legacy `ENV KEY value` space form no longer runs quote-stripping (real Docker keeps quotes verbatim there, unlike the key=value form) and now splits on general whitespace instead of a literal space, so a tab-separated legacy line parses instead of being silently dropped.
1 parent 55ac20a commit 097dcc2

2 files changed

Lines changed: 115 additions & 22 deletions

File tree

codeanalyzer/artifacts/config_keys.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,19 @@
2626
ADDITIONAL namespace-`env` keys keyed on the bare var name, alongside the
2727
normal namespace-`yaml` dotted-path ones -- dual-minting is intentional
2828
(so `os.environ`/`os.getenv` reads bind to compose/k8s-declared vars too),
29-
never deduped away. Ids never collide with the plain yaml mint: the env
30-
mint's key is always the bare var name while the yaml mint's key always
31-
carries the full dotted path, so the two differ by construction for every
32-
shape this pass recognizes. This pass is shape-based, not filename/role
33-
gated -- any yaml artifact whose content happens to match mints the extra
34-
keys, matching this module's general overlay posture (permissive, never a
35-
schema validator).
29+
never deduped away. The `key` FIELD is always the bare var name (matching
30+
`env` namespace's exact-match resolution semantics), but the `id` cannot
31+
reuse that bare name unqualified: a TOP-LEVEL yaml key sharing the same
32+
name as a recognized env var (e.g. a document with both a bare
33+
`COMPOSE_ONLY_KEY:` entry and a `services.web.environment.COMPOSE_ONLY_KEY`
34+
one) would otherwise collide with the plain yaml mint's own bare-key id --
35+
the yaml mint's key is USUALLY a longer dotted path that can't collide, but
36+
not always (a top-level leaf's dotted path IS just its bare name). Same fix
37+
as the dockerfile ARG case: the env-dual-mint's id is disambiguated with an
38+
internal `env.` prefix (`_build_keys`'s `id_key`), the `key` field itself
39+
unaffected. This pass is shape-based, not filename/role gated -- any yaml
40+
artifact whose content happens to match mints the extra keys, matching this
41+
module's general overlay posture (permissive, never a schema validator).
3642
3743
Span precision differs by shape: env/properties/ini/dockerfile are
3844
line-oriented, so the parse itself knows the exact defining line. yaml/
@@ -231,18 +237,30 @@ def _join_continuations(lines: List[str], start_i: int) -> Tuple[str, int]:
231237

232238

233239
def _split_ws_respecting_quotes(s: str) -> List[str]:
234-
"""Whitespace-split `s`, except inside a matching `'`/`"` span (a quoted
235-
value may contain spaces) -- quote characters stay IN the returned
236-
tokens, stripped afterward by `_env_value` so there is one quote-
237-
stripping implementation, not two."""
240+
r"""Whitespace-split `s`, except inside a matching `'`/`"` span (a quoted
241+
value may contain spaces) or right after an unquoted `\` -- a backslash
242+
escapes the next character (`\ ` keeps a literal space in the token
243+
instead of splitting there, `\\` collapses to one literal backslash),
244+
mirroring Docker's own shell-style ENV splitting (moby's `Rex\ The\
245+
Dog` example). A dangling trailing `\` with nothing to escape is kept
246+
literally rather than raising -- a real trailing continuation backslash
247+
is already stripped upstream by `_join_continuations`, so this is only
248+
a defensive fallback. Quote characters stay IN the returned tokens,
249+
stripped afterward by `_env_value` so there is one quote-stripping
250+
implementation, not two."""
238251
tokens: List[str] = []
239252
buf: List[str] = []
240253
quote: Optional[str] = None
241-
for ch in s:
254+
i, n = 0, len(s)
255+
while i < n:
256+
ch = s[i]
242257
if quote:
243258
buf.append(ch)
244259
if ch == quote:
245260
quote = None
261+
elif ch == "\\":
262+
i += 1
263+
buf.append(s[i] if i < n else ch)
246264
elif ch in "'\"":
247265
quote = ch
248266
buf.append(ch)
@@ -252,6 +270,7 @@ def _split_ws_respecting_quotes(s: str) -> List[str]:
252270
buf = []
253271
else:
254272
buf.append(ch)
273+
i += 1
255274
if buf:
256275
tokens.append("".join(buf))
257276
return tokens
@@ -262,8 +281,12 @@ def _dockerfile_env_entries(text: str, lines: List[str]) -> List[_Entry]:
262281
`ENV a=1 b=2`, and the legacy single-key `ENV K v` space form (Docker's
263282
own disambiguation rule: the token right after `ENV` decides the form --
264283
a `=` in it means one-or-more `key=value` pairs; no `=` means the
265-
legacy form, where the key is the first word and the REST of the line,
266-
verbatim, is the value)."""
284+
legacy form, where the key is the first word and the REST of the line
285+
is the value). The legacy form's value is taken VERBATIM -- unlike the
286+
`key=value` form, real Docker does no quote processing there at all
287+
(moby's `parseNameVal`), so `ENV NAME "John Doe"` keeps its quotes; the
288+
key/value separator is general whitespace (a tab is as legal as a
289+
space), not a literal `" "`."""
267290
out: List[_Entry] = []
268291
i, n = 0, len(lines)
269292
while i < n:
@@ -282,9 +305,9 @@ def _dockerfile_env_entries(text: str, lines: List[str]) -> List[_Entry]:
282305
if sep and _ENV_KEY_NAME.match(key):
283306
out.append((key, _env_value(raw_val), span))
284307
else:
285-
key, sep, raw_val = rest.partition(" ")
286-
if sep and _ENV_KEY_NAME.match(key):
287-
out.append((key, _env_value(raw_val.strip()), span))
308+
parts = rest.split(None, 1)
309+
if len(parts) == 2 and _ENV_KEY_NAME.match(parts[0]):
310+
out.append((parts[0], parts[1], span))
288311
i += 1
289312
return out
290313

@@ -454,10 +477,14 @@ def _build_keys(
454477
bool/None that needs that coercion.
455478
456479
`id_key` remaps `dotted_key` for ID CONSTRUCTION only -- the `.key` FIELD
457-
always stays the bare `dotted_key`. Used solely so a Dockerfile ARG's id
458-
can't collide with an ENV of the same name minting the same bare-name id
459-
in the "env" namespace (`ARG X` then `ENV X=$X` is a common promotion
460-
idiom); every other namespace omits it, preserving today's id shape."""
480+
always stays the bare `dotted_key`. Two call sites need it, both to keep
481+
a bare-name mint from colliding with another mint that happens to use
482+
the same bare name for its OWN id: a Dockerfile ARG's id (`ARG X` then
483+
`ENV X=$X` is a common promotion idiom -- both would otherwise mint id
484+
`.../@key/X`), and a yaml artifact's compose/k8s env-dual-mint id (a
485+
top-level yaml key sharing a name with a recognized env var would
486+
otherwise collide with the plain yaml mint's own bare-key id). Every
487+
other namespace omits it, preserving today's id shape."""
461488
coalesced: Dict[str, Tuple[object, Optional[Span]]] = {}
462489
for dotted_key, value, span in entries:
463490
coalesced[dotted_key] = (value, span)
@@ -545,7 +572,10 @@ def extract_config_keys(
545572
]
546573
keys = (
547574
_build_keys(artifact.id, "yaml", _parse_yaml(full_text, lines), capture_value)
548-
+ _build_keys(artifact.id, "env", env_entries, capture_value)
575+
+ _build_keys(
576+
artifact.id, "env", env_entries, capture_value,
577+
id_key=lambda k: f"env.{k}",
578+
)
549579
)
550580
else:
551581
parser = _NAMESPACE_PARSERS.get(artifact.format)

test/test_config_key_extraction.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,22 @@ def test_dockerfile_env_legacy_space_form():
242242
assert _by_key(keys)["MY_NAME"].value == "John Doe"
243243

244244

245+
def test_dockerfile_env_legacy_space_form_keeps_quotes_verbatim():
246+
# Real Docker does NO quote processing in the legacy form (moby's
247+
# parseNameVal) -- unlike the key=value form, quotes stay in the value.
248+
art = _artifact("Dockerfile", "dockerfile")
249+
keys, ok = extract_config_keys(art, 'ENV MY_NAME "John Doe"\n', True)
250+
assert ok is True
251+
assert _by_key(keys)["MY_NAME"].value == '"John Doe"'
252+
253+
254+
def test_dockerfile_env_legacy_space_form_tab_separated():
255+
art = _artifact("Dockerfile", "dockerfile")
256+
keys, ok = extract_config_keys(art, "ENV MY_NAME\tJohn Doe\n", True)
257+
assert ok is True
258+
assert _by_key(keys)["MY_NAME"].value == "John Doe"
259+
260+
245261
def test_dockerfile_env_quoted_values_in_multi_key_form():
246262
art = _artifact("Dockerfile", "dockerfile")
247263
keys, ok = extract_config_keys(art, 'ENV GREETING="hello world" OTHER=2\n', True)
@@ -260,6 +276,29 @@ def test_dockerfile_env_backslash_continuation():
260276
assert by["A"].value == "1" and by["B"].value == "2" and by["C"].value == "3"
261277

262278

279+
def test_dockerfile_env_docker_docs_example_escaped_spaces_and_continuation():
280+
# Docker's own ENV docs example: a quoted value, a backslash-escaped-
281+
# space value, and a third key on a continuation line -- all three exact.
282+
text = (
283+
'ENV MY_NAME="John Doe" MY_DOG=Rex\\ The\\ Dog \\\n'
284+
' MY_CAT=fluffy\n'
285+
)
286+
art = _artifact("Dockerfile", "dockerfile")
287+
keys, ok = extract_config_keys(art, text, True)
288+
assert ok is True
289+
by = _by_key(keys)
290+
assert by["MY_NAME"].value == "John Doe"
291+
assert by["MY_DOG"].value == "Rex The Dog"
292+
assert by["MY_CAT"].value == "fluffy"
293+
294+
295+
def test_dockerfile_env_double_backslash_collapses_to_one():
296+
art = _artifact("Dockerfile", "dockerfile")
297+
keys, ok = extract_config_keys(art, "ENV PATTERN=a\\\\b\n", True)
298+
assert ok is True
299+
assert _by_key(keys)["PATTERN"].value == "a\\b"
300+
301+
263302
def test_dockerfile_arg_with_and_without_default():
264303
text = "ARG BUILD_REV\nARG VERSION=1.0\n"
265304
art = _artifact("Dockerfile", "dockerfile")
@@ -404,6 +443,30 @@ def test_env_recognition_scoped_to_yaml_only_not_json_or_toml():
404443
assert all(k.namespace == "json" for k in keys)
405444

406445

446+
def test_yaml_top_level_key_and_env_dual_mint_same_name_no_id_collision():
447+
# Reviewer-reported: a top-level yaml key sharing a name with a
448+
# compose/k8s-recognized env var, in the SAME file, must not collide on
449+
# id -- the "differs by construction" claim only holds for the RECOGNIZED
450+
# shapes' own dotted paths, not for an unrelated top-level leaf.
451+
text = textwrap.dedent("""\
452+
COMPOSE_ONLY_KEY: top
453+
services:
454+
web:
455+
environment:
456+
COMPOSE_ONLY_KEY: nested
457+
""")
458+
art = _artifact("docker-compose.yml", "yaml")
459+
keys, ok = extract_config_keys(art, text, True)
460+
assert ok is True
461+
matches = [k for k in keys if k.key == "COMPOSE_ONLY_KEY"]
462+
assert len(matches) == 2
463+
assert len({k.id for k in matches}) == 2
464+
assert {k.namespace for k in matches} == {"yaml", "env"}
465+
by_ns = {k.namespace: k for k in matches}
466+
assert by_ns["yaml"].value == "top"
467+
assert by_ns["env"].value == "nested"
468+
469+
407470
def test_compose_k8s_env_recognition_is_deterministic_and_sorted():
408471
text = textwrap.dedent("""\
409472
services:

0 commit comments

Comments
 (0)