@@ -115,6 +115,51 @@ def _validate_service_healthcheck(name: str, svc: dict[str, Any]) -> None:
115115 _HEALTHCHECK_SCALAR_VALIDATORS [key ](name , key , healthcheck [key ])
116116
117117
118+ def _classify_volume (volume : str ) -> tuple [str , str | None ]:
119+ r"""Classify one short-syntax volume entry: its kind, and its source when the kind is 'named'.
120+
121+ Returns `("anonymous", None)` for a colon-less entry (a bare container
122+ path), `("named", source)` for a colon-form entry whose source matches
123+ Docker's own volume-name grammar (`stores.NAME_PATTERN`, the identical
124+ pattern a secret/config name is checked against:
125+ `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`), or `("bind", None)` for every other
126+ colon-form entry -- a relative (`./rel`) or absolute (`/abs`) host path, a
127+ `~`-prefixed home-relative path (`~/data`, measured ACCEPT against
128+ `docker compose config` v5.1.2 with no top-level declaration -- Docker
129+ expands it against the invoking user's home directory), or a `${VAR}`
130+ reference (`$`, `{`, `}` are none of them pattern characters either).
131+
132+ Shared by `_validate_service_volumes` (only cares whether an entry is
133+ anonymous, to check its own shape) and `_named_volume_source` (only cares
134+ whether an entry is named, to extract its source for the cross-document
135+ declaration check) -- one function defines the colon-form split exactly
136+ once, rather than each re-deriving it. An earlier version of this
137+ file split named-vs-bind by "does not start with '.' or '/'" instead of
138+ the name grammar, which wrongly swept up every other bind-mount spelling
139+ (tilde in particular) into "named" -- an over-rejection once paired with
140+ the reference check below, since neither needs a top-level declaration.
141+
142+ A genuine Windows drive-letter source (`C:\\data:/var`) is NOT fixed by
143+ this pattern swap: `source, _, _ = volume.partition(":")` splits on the
144+ FIRST colon regardless, so for that entry `source` is just `"C"` -- a
145+ single letter, which is itself a syntactically valid NAME_PATTERN match --
146+ not the full `"C:\\data"` a naive reading of "doesn't match the pattern"
147+ might suggest. Docker's own parser special-cases a leading `<letter>:\\`
148+ to keep the drive letter attached to the source before ever comparing it
149+ to a name grammar; this module (and `emit.py`'s `_volume_flags`, which
150+ shares the same first-colon split for the same reason) does not. Measured,
151+ still REJECTs post-fix -- a pre-existing, uncatalogued residual from when
152+ this check was introduced (`_named_volume_source`'s Task 14 predecessor),
153+ not something this change introduces or was scoped to close.
154+ """
155+ if ":" not in volume :
156+ return "anonymous" , None
157+ source , _ , _ = volume .partition (":" )
158+ if stores .NAME_PATTERN .fullmatch (source ):
159+ return "named" , source
160+ return "bind" , None
161+
162+
118163def _validate_service_volumes (name : str , svc : dict [str , Any ]) -> None :
119164 """Check volumes is a list of short bind-mount entries."""
120165 volumes = svc .get ("volumes" )
@@ -128,35 +173,20 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
128173 if not isinstance (volume , str ):
129174 msg = f"service { name !r} : only short volume syntax is supported"
130175 raise UnsupportedComposeError (msg )
131- if ":" not in volume :
132- # Anonymous volume: must be an absolute container path.
133- if not volume .startswith ("/" ):
134- msg = f"service { name !r} : anonymous volume '{ volume } ' must be an absolute path"
135- raise UnsupportedComposeError (msg )
136- continue
137- # Colon-containing volume: bind mount (host path source) or named volume
138- # (bare identifier source) — both are accepted; podman creates a named
139- # volume implicitly on first reference.
176+ kind , _ = _classify_volume (volume )
177+ if kind == "anonymous" and not volume .startswith ("/" ):
178+ msg = f"service { name !r} : anonymous volume '{ volume } ' must be an absolute path"
179+ raise UnsupportedComposeError (msg )
180+ # A named or bind entry needs no further shape check here -- both are
181+ # accepted; podman creates a named volume implicitly on first
182+ # reference, and _validate_volume_references (below) is the one place
183+ # that cross-checks a named entry's source against a declaration.
140184
141185
142186def _named_volume_source (volume : str ) -> str | None :
143- """Return a colon-form volume entry's bare-identifier source, or None if it needs no declaration.
144-
145- Mirrors the same bind-vs-named split `emit.py`'s `_volume_flags` already
146- makes when translating a relative bind mount: a colon-form entry's source is
147- a bind mount when it starts with `.` or `/` (a host path, relative or
148- absolute) -- Docker requires no top-level declaration for either. No colon at
149- all means an anonymous volume (a bare container path), also declaration-free.
150- Anything else is a bare identifier: the *name* of a volume Docker requires to
151- be declared in the top-level `volumes:` block, even though podman itself
152- would create it implicitly on first reference regardless.
153- """
154- if ":" not in volume :
155- return None
156- source , _ , _ = volume .partition (":" )
157- if source .startswith (("." , "/" )):
158- return None
159- return source
187+ """Return a colon-form volume entry's bare-identifier source, or None if it needs no declaration."""
188+ kind , source = _classify_volume (volume )
189+ return source if kind == "named" else None
160190
161191
162192def _validate_image (name : str , svc : dict [str , Any ]) -> None :
@@ -796,14 +826,17 @@ def _validate_volume_references(compose: dict[str, Any], services: dict[str, Any
796826 is a list of strings, and that a colon-less entry is an absolute path), so
797827 `svc.get("volumes") or []` here is safe to iterate.
798828
799- A `${VAR}`-carrying source is exempted, matching every other host-state-
800- dependent grammar in this file (`values.has_variable`): measured, Docker
801- resolves the variable first and classifies the *result* -- the same
802- `${NAME}:/var` document ACCEPTS with no declaration when the shell resolves
803- `NAME` to a bind-mount path (`/abs` or `./rel`) and REJECTS undeclared when
804- it resolves to a bare identifier. Which rule applies is a fact about the
805- shell that will later run the generated script, not about this document, so
806- it does not bind here either way.
829+ A `${VAR}`-carrying source needs no separate carve-out the way other
830+ host-state-dependent grammars in this file need `values.has_variable`:
831+ measured, Docker resolves the variable first and classifies the *result*
832+ -- the same `${NAME}:/var` document ACCEPTS with no declaration when the
833+ shell resolves `NAME` to a bind-mount path (`/abs` or `./rel`) and REJECTS
834+ undeclared when it resolves to a bare identifier, a fact about the shell
835+ that will later run the generated script, not about this document, so it
836+ does not bind here either way. `_named_volume_source` already returns None
837+ for any `${...}`-carrying source unconditionally -- `$`, `{`, and `}` are
838+ none of them `stores.NAME_PATTERN` characters -- so this function never
839+ needs to ask `values.has_variable` itself.
807840 """
808841 top_volumes = compose .get ("volumes" )
809842 if top_volumes is not None and not isinstance (top_volumes , dict ):
@@ -813,7 +846,7 @@ def _validate_volume_references(compose: dict[str, Any], services: dict[str, Any
813846 for name , svc in services .items ():
814847 for volume in svc .get ("volumes" ) or []:
815848 source = _named_volume_source (volume )
816- if source is None or values . has_variable ( source ) :
849+ if source is None :
817850 continue
818851 if source not in declared :
819852 msg = f"service { name !r} : refers to undefined volume { source !r} "
0 commit comments