@@ -162,6 +162,18 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
162162
163163_VOLUME_LONG_TYPES = ("bind" , "volume" , "tmpfs" )
164164_VOLUME_LONG_KEYS = {"type" , "source" , "target" , "read_only" , "consistency" }
165+ # Docker's own per-type nested option map keys (measured, docker compose config
166+ # v5.1.2). `create_host_path`/`nocopy` are real docker keys, so they land here
167+ # (not treated as unknown) -- rule-two refuses them separately, in
168+ # `_validate_volume_options`, with a "not supported" message rather than an
169+ # "unknown key" one.
170+ _VOLUME_OPTION_KEYS = {
171+ "bind" : {"propagation" , "selinux" , "create_host_path" },
172+ "volume" : {"subpath" , "nocopy" },
173+ "tmpfs" : {"size" , "mode" },
174+ }
175+ _PROPAGATION_VALUES = {"private" , "rprivate" , "shared" , "rshared" , "slave" , "rslave" }
176+ _SELINUX_VALUES = {"z" , "Z" }
165177
166178
167179def _validate_service_volumes (name : str , svc : dict [str , Any ]) -> None :
@@ -193,20 +205,24 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
193205def _validate_volume_long_form (name : str , entry : dict [str , Any ]) -> None :
194206 """Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).
195207
196- Scope A: type (bind/volume/tmpfs), source, target, read_only, consistency.
197- The nested bind/volume/tmpfs option maps fall out as unknown keys (refused,
198- tracked in planning/deferred.md); cluster/npipe/image types are refused
199- (podman cannot express them).
208+ type (bind/volume/tmpfs), source, target, read_only, consistency, plus the
209+ one nested option map matching `type` (a mismatched sub-map is refused --
210+ a deliberate stricter-than-docker check; docker accepts-and-ignores it).
211+ cluster/npipe/image types are refused (podman cannot express them).
212+
213+ `type` is validated before the unknown-key check (unlike every other field
214+ here) because the check itself needs `vtype` to know which sub-map key --
215+ `bind`/`volume`/`tmpfs` -- the entry is allowed to carry alongside it.
200216 """
201217 require_string_keys (f"service { name !r} : volume" , entry )
202- unknown = set (entry ) - _VOLUME_LONG_KEYS
203- if unknown :
204- msg = f"service { name !r} : volume: unsupported keys { sorted (unknown )} "
205- raise UnsupportedComposeError (msg )
206218 vtype = entry .get ("type" )
207219 if vtype not in _VOLUME_LONG_TYPES :
208220 msg = f"service { name !r} : volume 'type' must be one of { list (_VOLUME_LONG_TYPES )} "
209221 raise UnsupportedComposeError (msg )
222+ unknown = set (entry ) - _VOLUME_LONG_KEYS - {vtype }
223+ if unknown :
224+ msg = f"service { name !r} : volume: unsupported keys { sorted (unknown )} "
225+ raise UnsupportedComposeError (msg )
210226 target = entry .get ("target" )
211227 if not isinstance (target , str ):
212228 msg = f"service { name !r} : volume 'target' must be a string"
@@ -225,6 +241,86 @@ def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
225241 if "consistency" in entry and not isinstance (entry ["consistency" ], str ):
226242 msg = f"service { name !r} : volume 'consistency' must be a string"
227243 raise UnsupportedComposeError (msg )
244+ if vtype in entry :
245+ _validate_volume_options (name , vtype , entry [vtype ])
246+
247+
248+ def _validate_bind_options (name : str , options : dict [str , Any ]) -> None :
249+ """Check a long-form volume entry's `bind:` sub-map (measured, v5.1.2).
250+
251+ `create_host_path` is a real Docker key, but podman's `--mount` cannot
252+ express it, so it is refused with a "not supported" message rather than
253+ folded into the generic unknown-key check the caller already ran.
254+ """
255+ if "create_host_path" in options :
256+ msg = f"service { name !r} : bind 'create_host_path' is not supported (podman cannot express it)"
257+ raise UnsupportedComposeError (msg )
258+ if "propagation" in options and options ["propagation" ] not in _PROPAGATION_VALUES :
259+ msg = f"service { name !r} : bind 'propagation' must be one of { sorted (_PROPAGATION_VALUES )} "
260+ raise UnsupportedComposeError (msg )
261+ if "selinux" in options and options ["selinux" ] not in _SELINUX_VALUES :
262+ msg = f"service { name !r} : bind 'selinux' must be 'z' or 'Z'"
263+ raise UnsupportedComposeError (msg )
264+
265+
266+ def _validate_volume_type_options (name : str , options : dict [str , Any ]) -> None :
267+ """Check a long-form volume entry's `volume:` sub-map (measured, v5.1.2).
268+
269+ `nocopy` is a real Docker key, but podman's `--mount` cannot express it,
270+ so it is refused with a "not supported" message rather than folded into
271+ the generic unknown-key check the caller already ran.
272+ """
273+ if "nocopy" in options :
274+ msg = f"service { name !r} : volume 'nocopy' is not supported (podman cannot express it)"
275+ raise UnsupportedComposeError (msg )
276+ if "subpath" in options and not isinstance (options ["subpath" ], str ):
277+ msg = f"service { name !r} : volume 'subpath' must be a string"
278+ raise UnsupportedComposeError (msg )
279+
280+
281+ def _validate_tmpfs_options (name : str , options : dict [str , Any ]) -> None :
282+ """Check a long-form volume entry's `tmpfs:` sub-map (measured, v5.1.2).
283+
284+ `size` and `mode` are both unsigned in Docker's own decoder: a negative
285+ native number is refused ("size"/"cannot parse as uint32: -1 overflows"),
286+ measured against `docker compose config` v5.1.2. `mode` additionally
287+ tightens `validate_native_number`'s float acceptance down to integers
288+ only -- Docker itself accepts a float `mode` (it round-trips it verbatim),
289+ but podman 6.0.1's `crun` fails to mount it at run time ("crun: mount
290+ tmpfs: Invalid argument"), so accepting one here would be a real green in
291+ `docker compose config` that is a false green for the generated script.
292+ """
293+ if "size" in options :
294+ size = options ["size" ]
295+ values .validate_size (name , "tmpfs size" , size , allow_fractional = False )
296+ if isinstance (size , (int , float )) and not isinstance (size , bool ) and size < 0 :
297+ msg = f"service { name !r} : tmpfs size must be non-negative"
298+ raise UnsupportedComposeError (msg )
299+ if "mode" in options :
300+ mode = options ["mode" ]
301+ if isinstance (mode , bool ) or not isinstance (mode , int ) or mode < 0 :
302+ msg = f"service { name !r} : tmpfs mode must be a non-negative integer"
303+ raise UnsupportedComposeError (msg )
304+
305+
306+ _VOLUME_OPTION_VALIDATORS : dict [str , Callable [[str , dict [str , Any ]], None ]] = {
307+ "bind" : _validate_bind_options ,
308+ "volume" : _validate_volume_type_options ,
309+ "tmpfs" : _validate_tmpfs_options ,
310+ }
311+
312+
313+ def _validate_volume_options (name : str , vtype : str , options : Any ) -> None : # noqa: ANN401 - Compose values are untyped YAML/JSON
314+ """Check a long-form volume entry's nested option map (the one matching `type`), measured v5.1.2."""
315+ if not isinstance (options , dict ):
316+ msg = f"service { name !r} : { vtype } options must be a mapping"
317+ raise UnsupportedComposeError (msg )
318+ require_string_keys (f"service { name !r} : { vtype } options" , options )
319+ unknown = set (options ) - _VOLUME_OPTION_KEYS [vtype ]
320+ if unknown :
321+ msg = f"service { name !r} : { vtype } options: unsupported keys { sorted (unknown )} "
322+ raise UnsupportedComposeError (msg )
323+ _VOLUME_OPTION_VALIDATORS [vtype ](name , options )
228324
229325
230326def _validate_volume_long_form_source (name : str , vtype : str , source : Any ) -> None : # noqa: ANN401 - Compose values are untyped YAML/JSON
0 commit comments