@@ -130,19 +130,45 @@ def _validate_service_healthcheck(name: str, svc: dict[str, Any]) -> None:
130130 _HEALTHCHECK_SCALAR_VALIDATORS [key ](name , key , healthcheck [key ])
131131
132132
133+ # A short-form volume source Docker reads as a host path rather than a volume
134+ # name. Measured over 19 spellings against `docker compose config` v5.1.2.
135+ _BIND_SOURCE_PREFIXES = ("." , "/" , "~" )
136+
137+
138+ def _is_named_volume_source (source : str ) -> bool :
139+ r"""Whether a SHORT-form volume source names a volume, as Docker reads it.
140+
141+ A leading `.`, `/` or `~` makes it a host path; every other spelling is a
142+ volume name, `a/b`, `a b` and `a@b` included -- each measured as
143+ `refers to undefined volume` with no top-level declaration. The name
144+ grammar does not decide this and must not be asked to: it would call
145+ those three binds, which is exactly how they stayed accepted until issue
146+ 143 measured them. An earlier split used this same leading-character rule
147+ without `~` and swept `~/data` into "named" wrongly; `~` was the missing
148+ prefix, not the grammar.
149+
150+ A `${VAR}`-carrying source stays unnamed on purpose: what it resolves to
151+ is a fact about the shell that will run the script, not about the
152+ document, the carve-out `_validate_volume_references` documents.
153+
154+ No drive-shaped entry (`C:\\data:/var`, `v:/data`) reaches this:
155+ `_validate_service_volumes` refuses the family first, so a `named` verdict
156+ here always names a volume Docker would name too.
157+ """
158+ return not source .startswith (_BIND_SOURCE_PREFIXES ) and not values .has_variable (source )
159+
160+
133161def _classify_volume (volume : str ) -> tuple [str , str | None ]:
134162 r"""Classify one short-syntax volume entry: its kind, and its source when the kind is 'named'.
135163
136164 Returns `("anonymous", None)` for a colon-less entry (a bare container
137- path), `("named", source)` for a colon-form entry whose source matches
138- Docker's own volume-name grammar (`stores.NAME_PATTERN`, the identical
139- pattern a secret/config name is checked against:
140- `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`), or `("bind", None)` for every other
141- colon-form entry -- a relative (`./rel`) or absolute (`/abs`) host path, a
142- `~`-prefixed home-relative path (`~/data`, measured ACCEPT against
143- `docker compose config` v5.1.2 with no top-level declaration -- Docker
144- expands it against the invoking user's home directory), or a `${VAR}`
145- reference (`$`, `{`, `}` are none of them pattern characters either).
165+ path), `("named", source)` per `_is_named_volume_source` above, or
166+ `("bind", None)` for every other colon-form entry -- a relative (`./rel`)
167+ or absolute (`/abs`) host path, a `~`-prefixed home-relative path
168+ (`~/data`, measured ACCEPT against `docker compose config` v5.1.2 with no
169+ top-level declaration -- Docker expands it against the invoking user's
170+ home directory), or a `${VAR}` reference (`$`, `{`, `}` are none of them
171+ name-grammar characters either).
146172
147173 Shared by `_validate_service_volumes` (only cares whether an entry is
148174 anonymous, to check its own shape) and `_named_volume_source` (only cares
@@ -162,7 +188,7 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
162188 if ":" not in volume :
163189 return "anonymous" , None
164190 source , _ , _ = volume .partition (":" )
165- if stores . NAME_PATTERN . fullmatch (source ):
191+ if _is_named_volume_source (source ):
166192 return "named" , source
167193 return "bind" , None
168194
@@ -427,14 +453,17 @@ def _named_volume_source(volume: object) -> str | None:
427453 """Return a volume entry's bare-identifier named source, or None if it needs no declaration.
428454
429455 A colon-form short-syntax string is classified via `_classify_volume`. A
430- long-syntax mapping needs its own check: only a `volume`-type entry names
431- a volume at all, and only when its `source` is a bare identifier (an
456+ long-syntax mapping needs its own check, and a laxer one: `type` has
457+ already said which kind the entry is, so a `volume`-type entry names a
458+ volume whatever its `source` looks like -- `source: /abs` and
459+ `source: ./r` are both `refers to undefined volume` to Docker, where the
460+ short form reads the same strings as host paths (measured, v5.1.2). An
432461 absent source is an anonymous volume; a `bind`/`tmpfs` entry's `source`,
433- if any, is a host path, never a name to cross-check) .
462+ if any, is a host path, never a name to cross-check.
434463 """
435464 if isinstance (volume , dict ):
436465 source = volume .get ("source" )
437- if volume .get ("type" ) == "volume" and isinstance (source , str ) and stores . NAME_PATTERN . fullmatch (source ):
466+ if volume .get ("type" ) == "volume" and isinstance (source , str ) and not values . has_variable (source ):
438467 return source
439468 return None
440469 # _validate_service_volumes has already confirmed every non-dict entry is
@@ -1105,9 +1134,8 @@ def _validate_volume_references(compose: dict[str, Any], services: dict[str, Any
11051134 undeclared when it resolves to a bare identifier, a fact about the shell
11061135 that will later run the generated script, not about this document, so it
11071136 does not bind here either way. `_named_volume_source` already returns None
1108- for any `${...}`-carrying source unconditionally -- `$`, `{`, and `}` are
1109- none of them `stores.NAME_PATTERN` characters -- so this function never
1110- needs to ask `values.has_variable` itself.
1137+ for any `${...}`-carrying source, in both syntaxes and by an explicit
1138+ `values.has_variable` test, so this function never needs to ask again.
11111139 """
11121140 top_volumes = compose .get ("volumes" )
11131141 if top_volumes is not None and not isinstance (top_volumes , dict ):
@@ -1175,6 +1203,7 @@ def _validate_network_entry_value(name: str, network: str, value: Any) -> None:
11751203 can never satisfy "must be a mapping" regardless of its runtime value --
11761204 the rejection is a fact about the document, not the host.
11771205 """
1206+ values .validate_compose_name (f"service { name !r} : network name" , network )
11781207 if value is None :
11791208 return
11801209 if not isinstance (value , dict ):
@@ -1558,6 +1587,25 @@ def _validate_top_level_scalar_strings(compose: dict[str, Any]) -> None:
15581587 raise UnsupportedComposeError (msg )
15591588
15601589
1590+ # The top-level blocks whose KEYS Docker holds to its name grammar. `networks`
1591+ # is absent by measurement, not oversight: `networks: {"a b": {}}` on its own
1592+ # is accepted by `docker compose config` v5.1.2, and the grammar bites on the
1593+ # service's long-form `networks` mapping key instead (`_validate_network_entries`).
1594+ NAME_CHECKED_TOP_LEVEL_BLOCKS = ("services" , "volumes" , "secrets" , "configs" )
1595+
1596+
1597+ def _validate_top_level_names (compose : dict [str , Any ]) -> None :
1598+ """Every key of every name-checked top-level block, against Docker's one grammar.
1599+
1600+ A block that is not a mapping is left to the validator that owns its shape.
1601+ """
1602+ for block in NAME_CHECKED_TOP_LEVEL_BLOCKS :
1603+ definitions = compose .get (block )
1604+ if isinstance (definitions , dict ):
1605+ for name in definitions :
1606+ values .validate_compose_name (f"top-level { block !r} : name" , name )
1607+
1608+
15611609def validate (compose : dict [str , Any ]) -> list [str ]:
15621610 """Check the compose document against the supported subset.
15631611
@@ -1579,6 +1627,7 @@ def validate(compose: dict[str, Any]) -> list[str]:
15791627 raise UnsupportedComposeError (msg )
15801628 _reject_null_top_level_blocks (compose )
15811629 _validate_top_level_scalar_strings (compose )
1630+ _validate_top_level_names (compose )
15821631 if "networks" in compose :
15831632 warnings .append ("ignoring top-level 'networks' (all services share the pod namespace)" )
15841633 if "volumes" in compose :
0 commit comments