Skip to content

Commit f5f203f

Browse files
authored
fix: refuse a single-letter volume source in the short syntax (#108)
1 parent e69454d commit f5f203f

5 files changed

Lines changed: 90 additions & 49 deletions

File tree

‎compose2pod/parsing.py‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,10 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
139139
(tilde in particular) into "named" -- an over-rejection once paired with
140140
the reference check below, since neither needs a top-level declaration.
141141
142-
A drive-qualified source with a target (`C:\data:/var`) never reaches this
143-
split: `_validate_service_volumes` refuses it first, so the leading `C`
144-
this would otherwise read as a one-character volume name is not a verdict
145-
anyone sees. A drive-shaped entry with no target (`C:\data`) does reach it,
146-
and is still classified by the name grammar -- see issue 105.
142+
No drive-shaped entry (`C:\data:/var`, `v:/data`) reaches this split:
143+
`_validate_service_volumes` refuses the family first. So the name grammar
144+
below never sees a one-character source, and the `named` verdict it can
145+
return always names a volume Docker would name too.
147146
"""
148147
if ":" not in volume:
149148
return "anonymous", None
@@ -153,21 +152,22 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
153152
return "bind", None
154153

155154

156-
# A source Docker reads as a Windows drive path, with a target after it: any
157-
# single letter, either separator, then a further colon. Measured against
158-
# `docker compose config` v5.1.2, the drive marker is what the letter means --
159-
# `C:\data:/var` and `C:/data:/var` are binds on `{source: C:\data, target:
160-
# /var}`, and so is `v:/data:ro`, read as `{source: v:/data, target: ro}`
161-
# rather than the named volume `v` its spelling suggests. Two letters
162-
# (`CC:\data:/var`) is an ordinary named-volume reference instead.
155+
# A short-syntax entry Docker reads as a Windows drive path. The marker is one
156+
# leading letter and a colon, whatever the letter is: measured against `docker
157+
# compose config` v5.1.2, `C:\data:/var` and `v:/data:ro` are both binds whose
158+
# source keeps the colon (`{source: v:/data, target: ro}`, not the named
159+
# volume `v` the spelling suggests), and `C:\data`, `v:/data`, `a:/var`, `v:`
160+
# are all anonymous volumes whose target is the whole string -- the last three
161+
# even when that letter is declared top-level, a declaration Docker ignores.
162+
# Two letters (`CC:\data:/var`) is an ordinary named-volume reference instead.
163163
#
164-
# The trailing colon is load-bearing: it is what makes the source carry a
165-
# colon, which is the thing podman's `-v` cannot take (it splits a spec into
166-
# at most source:target:options, measured against podman 4.9.3). Without it --
167-
# `C:\data`, `v:/data` -- Docker reads an anonymous volume whose target is the
168-
# whole string, a different divergence with its own verdict, tracked in issue
169-
# 105 rather than refused here.
170-
_WINDOWS_DRIVE_SOURCE = re.compile(r"^[a-zA-Z]:[\\/][^:]*:")
164+
# podman refuses every mount either reading makes (measured, podman 4.9.3): a
165+
# colon inside a source has nowhere to go in a `-v` spec, which splits into at
166+
# most source:target:options (`invalid option type "/var"`), and a container
167+
# path that is not absolute is refused outright (`invalid container path`).
168+
# So the whole family is a rule-two refusal, and a one-character volume name
169+
# is reachable only through the long form, where Docker honours `source: v`.
170+
_DRIVE_SHAPED_SOURCE = re.compile(r"^[a-zA-Z]:")
171171

172172

173173
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs", "image")
@@ -203,13 +203,14 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
203203
if not isinstance(volume, str):
204204
msg = f"service {name!r}: volume entry must be a string or mapping"
205205
raise UnsupportedComposeError(msg)
206-
if _WINDOWS_DRIVE_SOURCE.match(volume):
207-
# Refused before classification, so the drive colon is never read
208-
# as the end of a one-character volume name (measured, podman
209-
# 4.9.3: `invalid option type "/var"`).
206+
if _DRIVE_SHAPED_SOURCE.match(volume):
207+
# Refused before classification, so a single leading letter is
208+
# never read as a one-character volume name the way Docker never
209+
# reads it either.
210210
msg = (
211-
f"service {name!r}: volume {volume!r}: a Windows drive-letter path "
212-
"is not supported (podman cannot express it)"
211+
f"service {name!r}: volume {volume!r}: a leading single letter is a Windows drive path "
212+
"to Docker, not a volume name, and podman cannot express the mount it makes "
213+
"(name a one-character volume through the long form instead)"
213214
)
214215
raise UnsupportedComposeError(msg)
215216
kind, _ = _classify_volume(volume)

‎docs/adr/0006-docker-rejection-parity.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ Two rules, one direction each. A document `docker compose config` rejects, compo
55
rootless runners and accepting a file Docker refuses turns a hard error into a green CI run. A
66
document Docker accepts, compose2pod accepts whenever podman can express it inside a pod. Where
77
podman cannot, that is a legitimate refusal (`network_mode`; `sysctls: ["a"]` with no value;
8-
`volumes: ["a"]`, which podman rejects as a relative mount target; a drive-qualified volume source
9-
such as `C:\data:/var`, whose colon podman's `-v` cannot carry), and where compose2pod merely
8+
`volumes: ["a"]`, which podman rejects as a relative mount target; a short-form volume entry whose
9+
source is a single letter, which Docker reads as a Windows drive path (`C:\data:/var`, and `v:/data`
10+
too) and podman cannot mount either way, leaving the long form as the way to name a one-character
11+
volume), and where compose2pod merely
1012
does not parse a form yet, that is a tracked limitation, never a design position. Docker's
1113
verdict binds only on the document, not the host: `env_file` existence, `${VAR:?}`, and a
1214
negative on a top-level numeric key are facts about the machine that runs the script and are
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
app:
3+
image: nginx
4+
volumes:
5+
- 'v:/data'
6+
volumes:
7+
v:

‎tests/conformance/test_corpus.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,19 @@ def test_volume_windows_drive_letter_bind_is_a_catalogued_over_rejection(
143143
"""
144144
path = Path(__file__).parent / "corpus" / "volume_windows_drive_letter_bind.yaml"
145145
assert assert_rule(yaml.safe_load(path.read_text())) == "over-reject"
146+
147+
148+
def test_volume_single_letter_source_is_a_catalogued_over_rejection(
149+
assert_rule: Callable[[dict[str, Any]], str],
150+
) -> None:
151+
"""Docker accepts `volumes: ['v:/data']` -- as an anonymous volume, not as the declared `v`.
152+
153+
The declaration in the file is deliberate: Docker ignores it, because a
154+
leading single letter is a drive marker and never a volume name. Both
155+
oracles once accepted this document while meaning different mounts, which
156+
is a divergence the harness cannot see -- it compares verdicts, not
157+
meanings. Refusing it makes the disagreement visible as an over-rejection,
158+
and asserting the verdict here keeps it that way.
159+
"""
160+
path = Path(__file__).parent / "corpus" / "volume_single_letter_source.yaml"
161+
assert assert_rule(yaml.safe_load(path.read_text())) == "over-reject"

‎tests/test_parsing.py‎

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1739,29 +1739,44 @@ def test_tilde_bind_mount_needs_no_declaration() -> None:
17391739
validate(_doc(volumes=["~/data:/var"]))
17401740

17411741

1742-
@pytest.mark.parametrize("entry", ["C:\\data:/var", "C:/data:/var", "c:\\data:/var", "C:\\data:/var:ro", "v:/data:ro"])
1743-
def test_drive_qualified_volume_source_is_refused_with_the_podman_reason(entry: str) -> None:
1744-
# Measured on both sides. `docker compose config` v5.1.2 ACCEPTS each of
1745-
# these as a bind whose source keeps the drive colon -- including
1746-
# `v:/data:ro`, read as `{source: v:/data, target: ro}` rather than the
1747-
# named volume its spelling suggests. podman 4.9.3 REJECTS the `-v` spec
1748-
# they render to (`invalid option type "/var"`): a spec splits into at
1749-
# most source:target:options, so a colon inside the source pushes the
1750-
# target into the option slot. Rule two -- a refusal that cites podman,
1751-
# where the old one named a phantom volume 'C' the document never wrote.
1752-
with pytest.raises(UnsupportedComposeError, match="Windows drive-letter path"):
1742+
@pytest.mark.parametrize(
1743+
"entry",
1744+
[
1745+
"C:\\data:/var",
1746+
"C:/data:/var",
1747+
"c:\\data:/var",
1748+
"C:\\data:/var:ro",
1749+
"C:\\data",
1750+
"C:data:/var",
1751+
"v:/data",
1752+
"a:/var",
1753+
"v:",
1754+
],
1755+
)
1756+
def test_single_letter_volume_source_is_refused_with_the_podman_reason(entry: str) -> None:
1757+
# Measured against `docker compose config` v5.1.2: a leading single letter
1758+
# is a Windows drive marker whatever the letter is, so none of these names
1759+
# a volume. Docker reads a source keeping the drive colon
1760+
# (`C:\data:/var`, `v:/data:ro`), or an anonymous volume whose target is
1761+
# the whole string (`C:\data`, `v:/data`, `a:/var`, `v:`) -- even when the
1762+
# letter is declared top-level, which Docker ignores. podman 4.9.3 refuses
1763+
# every mount either reading makes: a colon inside a source has nowhere to
1764+
# go in a `-v` spec (`invalid option type "/var"`), and a container path
1765+
# that is not absolute is refused outright (`invalid container path`).
1766+
with pytest.raises(UnsupportedComposeError, match="Windows drive path"):
17531767
validate(_doc(volumes=[entry]))
17541768

17551769

1756-
def test_drive_shaped_entry_without_a_target_keeps_its_old_verdict() -> None:
1757-
# `C:\data` carries one colon, so Docker reads an anonymous volume whose
1758-
# target is the whole string, and podman refuses it as a non-absolute
1759-
# container path. Neither the source-with-a-colon shape the refusal above
1760-
# is about, nor a form this change rules on: it keeps the verdict it has
1761-
# always had, tracked in issue 105 with the other drive-adjacent
1762-
# spellings.
1763-
with pytest.raises(UnsupportedComposeError, match="undefined volume 'C'"):
1764-
validate(_doc(volumes=["C:\\data"]))
1770+
def test_a_one_character_volume_can_still_be_named_in_the_long_form() -> None:
1771+
# The refusal is a short-syntax artifact, so the long form keeps the
1772+
# capability: Docker honours `source: v` there (measured, v5.1.2 -- the
1773+
# document resolves to the declared volume `v`, not to a drive path), and
1774+
# podman expresses it as an ordinary named-volume mount.
1775+
compose = {
1776+
"services": {"app": {"image": "nginx", "volumes": [{"type": "volume", "source": "v", "target": "/data"}]}},
1777+
"volumes": {"v": None},
1778+
}
1779+
assert validate(compose) == ["ignoring top-level 'volumes' (podman creates named volumes on first reference)"]
17651780

17661781

17671782
def test_two_letter_drive_prefix_is_still_a_named_volume() -> None:
@@ -1923,8 +1938,8 @@ def _net_def_doc(definition: object) -> dict:
19231938

19241939

19251940
def _vol_def_doc(definition: object) -> dict:
1926-
"""One declared top-level volume 'v', referenced by a service, with 'definition' as its own body."""
1927-
return {"services": {"app": {"image": "nginx", "volumes": ["v:/data"]}}, "volumes": {"v": definition}}
1941+
"""One declared top-level volume 'vol', referenced by a service, with 'definition' as its own body."""
1942+
return {"services": {"app": {"image": "nginx", "volumes": ["vol:/data"]}}, "volumes": {"vol": definition}}
19281943

19291944

19301945
# Task 12: the top-level `networks:`/`volumes:` blocks' own DEFINITION contents

0 commit comments

Comments
 (0)