Skip to main content

podup/compose/types/
volume.rs

1//! Volume, secret, and config mount types.
2//!
3//! [`VolumeMount`] covers the `volumes:` list on a service (short and long forms).
4//! [`VolumeConfig`] describes top-level named volume definitions.
5//! [`ServiceSecretRef`] and [`ServiceConfigRef`] are the per-service `secrets:` /
6//! `configs:` attachment points (short = just the name, long = full options).
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use super::Labels;
12
13/// Volume mount type: `volume`, `bind`, `tmpfs`, `npipe`, or `cluster`.
14#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum VolumeType {
17	/// A named or anonymous managed volume.
18	Volume,
19	/// A host path bind mount.
20	Bind,
21	/// An in-memory tmpfs mount.
22	Tmpfs,
23	/// A Windows named-pipe mount.
24	Npipe,
25	/// A cluster (Swarm) volume.
26	Cluster,
27}
28
29/// Sub-options for a `bind`-type volume mount.
30#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct BindOptions {
32	/// Mount propagation mode (e.g. `rprivate`, `rshared`).
33	#[serde(skip_serializing_if = "Option::is_none")]
34	pub propagation: Option<String>,
35	/// Whether to create the host path if it does not exist.
36	#[serde(skip_serializing_if = "Option::is_none")]
37	pub create_host_path: Option<bool>,
38	/// SELinux relabeling option (`z` shared or `Z` private).
39	#[serde(skip_serializing_if = "Option::is_none")]
40	pub selinux: Option<String>,
41}
42
43/// Sub-options for a `volume`-type mount — nocopy flag and optional driver config.
44#[derive(Debug, Clone, Deserialize, Serialize, Default)]
45pub struct VolumeOptions {
46	/// Whether to skip copying existing target contents into the volume.
47	#[serde(skip_serializing_if = "Option::is_none")]
48	pub nocopy: Option<bool>,
49	/// Labels applied to the volume.
50	#[serde(default)]
51	pub labels: Labels,
52	/// Volume driver name and options.
53	#[serde(skip_serializing_if = "Option::is_none")]
54	pub driver_config: Option<DriverConfig>,
55	/// Path within the volume to mount instead of its root.
56	#[serde(skip_serializing_if = "Option::is_none")]
57	pub subpath: Option<String>,
58}
59
60/// Driver name and key-value options nested under `VolumeOptions`.
61#[derive(Debug, Clone, Deserialize, Serialize, Default)]
62pub struct DriverConfig {
63	/// Volume driver name.
64	#[serde(skip_serializing_if = "Option::is_none")]
65	pub name: Option<String>,
66	/// Driver-specific options.
67	#[serde(default, skip_serializing_if = "HashMap::is_empty")]
68	pub options: HashMap<String, String>,
69}
70
71/// Sub-options for a `tmpfs`-type mount — size and mode.
72#[derive(Debug, Clone, Deserialize, Serialize, Default)]
73pub struct TmpfsOptions {
74	/// Size of the tmpfs mount in bytes.
75	#[serde(skip_serializing_if = "Option::is_none")]
76	pub size: Option<u64>,
77	/// File mode of the tmpfs mount, stored as the actual permission bits.
78	///
79	/// A compose `mode:` is conventionally octal, so every spelling is normalised
80	/// to the same permission bits: a leading-zero string (`0700`), an explicit
81	/// `0o700`, and a bare `700` all yield `0o700` (448). Invalid input is a clear
82	/// error rather than a silent re-interpretation.
83	#[serde(
84		default,
85		deserialize_with = "deserialize_octal_mode",
86		skip_serializing_if = "Option::is_none"
87	)]
88	pub mode: Option<u32>,
89}
90
91/// Deserialize a tmpfs `mode` as octal-aware permission bits.
92///
93/// A compose `mode:` is conventionally octal, but serde_yaml decodes the spelling
94/// before we see it: a `0o`-prefixed literal arrives as its numeric value
95/// (`0o700` → 448), a leading-zero literal (`0700`) arrives as a string, and a
96/// bare `700` arrives unchanged. We normalise all of them to the actual
97/// permission bits (see [`int_mode_bits`] for the integer case), so the renderer
98/// can octal-encode a single canonical value. A string is parsed as octal,
99/// accepting an optional `0o` prefix and rejecting non-octal digits with a clear
100/// message.
101fn deserialize_octal_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
102where
103	D: serde::Deserializer<'de>,
104{
105	use serde::de::Error;
106
107	#[derive(Deserialize)]
108	#[serde(untagged)]
109	enum Raw {
110		Int(u32),
111		Str(String),
112	}
113
114	match Option::<Raw>::deserialize(deserializer)? {
115		None => Ok(None),
116		Some(Raw::Int(n)) => Ok(Some(int_mode_bits(n))),
117		Some(Raw::Str(s)) => {
118			let trimmed = s.trim();
119			let digits = trimmed
120				.strip_prefix("0o")
121				.or_else(|| trimmed.strip_prefix("0O"))
122				.unwrap_or(trimmed);
123			u32::from_str_radix(digits, 8).map(Some).map_err(|_| {
124				D::Error::custom(format!(
125					"invalid mode {s:?}: use octal notation like 0700 or 0o700"
126				))
127			})
128		}
129	}
130}
131
132/// Interpret a bare YAML integer `mode:` as octal permission bits.
133///
134/// A bare `700` is the octal file-mode the user typed, so its decimal digits are
135/// read as octal (`700` → `0o700` = 448). A digit of `8` or `9` cannot be a real
136/// octal digit, so such a value can only be a `0o` literal that serde_yaml has
137/// already decoded to its numeric value (`0o700` → 448, `0o755` → 493); it is
138/// taken as the actual permission bits verbatim. The same fallback covers an
139/// out-of-range octal reading, so the conversion never panics.
140fn int_mode_bits(n: u32) -> u32 {
141	u32::from_str_radix(&n.to_string(), 8).unwrap_or(n)
142}
143
144/// A volume mount entry — either a short-form string or a long-form typed block.
145#[derive(Debug, Clone, Deserialize, Serialize)]
146#[serde(untagged)]
147#[allow(clippy::large_enum_variant)]
148pub enum VolumeMount {
149	/// Short form: a `source:target[:options]` string.
150	Short(String),
151	/// Long form: an explicitly typed mount with per-type options.
152	Long {
153		/// Mount type selecting which options block applies.
154		#[serde(rename = "type")]
155		volume_type: VolumeType,
156		/// Mount source — host path, volume name, or omitted for anonymous/tmpfs.
157		#[serde(default, skip_serializing_if = "Option::is_none")]
158		source: Option<String>,
159		/// Mount target path inside the container.
160		target: String,
161		/// Whether the mount is read-only.
162		#[serde(default, skip_serializing_if = "Option::is_none")]
163		read_only: Option<bool>,
164		/// Bind-specific options, when `volume_type` is `bind`.
165		#[serde(default, skip_serializing_if = "Option::is_none")]
166		bind: Option<BindOptions>,
167		/// Volume-specific options, when `volume_type` is `volume`.
168		#[serde(default, skip_serializing_if = "Option::is_none")]
169		volume: Option<VolumeOptions>,
170		/// Tmpfs-specific options, when `volume_type` is `tmpfs`.
171		#[serde(default, skip_serializing_if = "Option::is_none")]
172		tmpfs: Option<TmpfsOptions>,
173		/// Mount consistency requirement (a no-op outside Docker Desktop).
174		#[serde(default, skip_serializing_if = "Option::is_none")]
175		consistency: Option<String>,
176	},
177}
178
179impl VolumeMount {
180	/// Returns the container-side target path of the mount.
181	pub fn target(&self) -> &str {
182		match self {
183			VolumeMount::Short(s) => {
184				let parts: Vec<&str> = s.splitn(3, ':').collect();
185				if parts.len() >= 2 {
186					parts[1]
187				} else {
188					parts[0]
189				}
190			}
191			VolumeMount::Long { target, .. } => target,
192		}
193	}
194}
195
196/// Named volume definition in the top-level `volumes:` block.
197#[derive(Debug, Clone, Deserialize, Serialize, Default)]
198#[non_exhaustive]
199pub struct VolumeConfig {
200	/// Volume driver name; the runtime default is used if absent.
201	#[serde(skip_serializing_if = "Option::is_none")]
202	pub driver: Option<String>,
203	/// Driver-specific options.
204	#[serde(default, skip_serializing_if = "HashMap::is_empty")]
205	pub driver_opts: HashMap<String, String>,
206	/// Whether the volume is externally managed and not created by podup.
207	#[serde(skip_serializing_if = "Option::is_none")]
208	pub external: Option<bool>,
209	/// Custom volume name overriding the project-prefixed default.
210	#[serde(skip_serializing_if = "Option::is_none")]
211	pub name: Option<String>,
212	/// Labels applied to the volume.
213	#[serde(default)]
214	pub labels: Labels,
215	/// Unrecognized keys preserved verbatim for round-tripping.
216	#[serde(flatten, default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
217	pub unknown: indexmap::IndexMap<String, serde_yaml::Value>,
218}
219
220/// Reference to a named config from a service — short form (name only) or long form with mount target.
221#[derive(Debug, Clone, Deserialize, Serialize)]
222#[serde(untagged)]
223pub enum ServiceConfigRef {
224	/// Short form: the name of a top-level config to mount.
225	Short(String),
226	/// Long form: a config name with mount target and ownership options.
227	Long {
228		/// Name of the top-level config to mount.
229		source: String,
230		/// Mount path inside the container; defaults to `/<source>` if absent.
231		#[serde(default, skip_serializing_if = "Option::is_none")]
232		target: Option<String>,
233		/// Owner UID of the mounted file.
234		#[serde(default, skip_serializing_if = "Option::is_none")]
235		uid: Option<String>,
236		/// Owner GID of the mounted file.
237		#[serde(default, skip_serializing_if = "Option::is_none")]
238		gid: Option<String>,
239		/// File permission mode of the mounted file. Octal notation per the
240		/// Compose Specification (`0444`, `0o444`, or a bare number), so a
241		/// leading-zero literal like `0444` is read as octal, not decimal.
242		#[serde(
243			default,
244			deserialize_with = "deserialize_octal_mode",
245			skip_serializing_if = "Option::is_none"
246		)]
247		mode: Option<u32>,
248	},
249}
250
251impl ServiceConfigRef {
252	/// Returns the name of the referenced top-level config.
253	pub fn source(&self) -> &str {
254		match self {
255			ServiceConfigRef::Short(s) => s,
256			ServiceConfigRef::Long { source, .. } => source,
257		}
258	}
259
260	/// Returns the container mount target, if specified.
261	pub fn target(&self) -> Option<&str> {
262		match self {
263			ServiceConfigRef::Short(_) => None,
264			ServiceConfigRef::Long { target, .. } => target.as_deref(),
265		}
266	}
267}
268
269/// Reference to a named secret from a service — short form (name only) or long form with mount target.
270#[derive(Debug, Clone, Deserialize, Serialize)]
271#[serde(untagged)]
272pub enum ServiceSecretRef {
273	/// Short form: the name of a top-level secret to mount.
274	Short(String),
275	/// Long form: a secret name with mount target and ownership options.
276	Long {
277		/// Name of the top-level secret to mount.
278		source: String,
279		/// Mount path; defaults to `/run/secrets/<source>` if absent.
280		#[serde(default, skip_serializing_if = "Option::is_none")]
281		target: Option<String>,
282		/// Owner UID of the mounted file.
283		#[serde(default, skip_serializing_if = "Option::is_none")]
284		uid: Option<String>,
285		/// Owner GID of the mounted file.
286		#[serde(default, skip_serializing_if = "Option::is_none")]
287		gid: Option<String>,
288		/// File permission mode of the mounted file. Octal notation per the
289		/// Compose Specification (`0444`, `0o444`, or a bare number), so a
290		/// leading-zero literal like `0444` is read as octal, not decimal.
291		#[serde(
292			default,
293			deserialize_with = "deserialize_octal_mode",
294			skip_serializing_if = "Option::is_none"
295		)]
296		mode: Option<u32>,
297	},
298}
299
300impl ServiceSecretRef {
301	/// Returns the name of the referenced top-level secret.
302	pub fn source(&self) -> &str {
303		match self {
304			ServiceSecretRef::Short(s) => s,
305			ServiceSecretRef::Long { source, .. } => source,
306		}
307	}
308
309	/// Returns the container mount target, if specified.
310	pub fn target(&self) -> Option<&str> {
311		match self {
312			ServiceSecretRef::Short(_) => None,
313			ServiceSecretRef::Long { target, .. } => target.as_deref(),
314		}
315	}
316}
317
318// ---------------------------------------------------------------------------
319// Unit tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324	use super::*;
325
326	// TmpfsOptions::mode octal parsing
327
328	#[test]
329	fn tmpfs_mode_octal_string_is_parsed_as_octal() {
330		// A leading-zero literal reaches us as a string and must parse as octal
331		// (0700 → 448 permission bits) instead of failing opaquely.
332		let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0700\"\n").unwrap();
333		assert_eq!(opts.mode, Some(0o700));
334		// An explicit 0o prefix in a string also works.
335		let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0o755\"\n").unwrap();
336		assert_eq!(opts.mode, Some(0o755));
337	}
338
339	#[test]
340	fn tmpfs_mode_octal_yaml_literal_is_preserved_as_bits() {
341		// A YAML `0o700` scalar is decoded to 448 by the parser; we keep those
342		// actual permission bits so the renderer's octal format round-trips.
343		let opts: TmpfsOptions = serde_yaml::from_str("mode: 0o700\n").unwrap();
344		assert_eq!(opts.mode, Some(0o700));
345	}
346
347	#[test]
348	fn tmpfs_mode_invalid_octal_is_clear_error() {
349		// A non-octal string is rejected with a clear error, not silently coerced.
350		let err = serde_yaml::from_str::<TmpfsOptions>("mode: \"0o9\"\n").unwrap_err();
351		assert!(err.to_string().contains("octal notation"), "got: {err}");
352	}
353
354	#[test]
355	fn tmpfs_mode_bare_decimal_is_interpreted_as_octal() {
356		// A bare `700` is the octal file-mode the user typed, not a decimal value:
357		// it must yield the same permission bits as `0700`/`0o700` (issue #917)
358		// instead of being octal-encoded a second time at render time.
359		let opts: TmpfsOptions = serde_yaml::from_str("mode: 700\n").unwrap();
360		assert_eq!(opts.mode, Some(0o700));
361		let opts: TmpfsOptions = serde_yaml::from_str("mode: 644\n").unwrap();
362		assert_eq!(opts.mode, Some(0o644));
363	}
364
365	#[test]
366	fn int_mode_bits_treats_decoded_0o_literals_as_bits() {
367		// A value carrying an 8/9 digit can only be a `0o` literal serde_yaml
368		// already decoded (`0o755` → 493), so it is taken as the bits verbatim;
369		// a value of valid octal digits is read as the octal the user typed.
370		assert_eq!(int_mode_bits(700), 0o700);
371		assert_eq!(int_mode_bits(0o755), 0o755);
372		assert_eq!(int_mode_bits(0o700), 0o700);
373	}
374
375	// VolumeMount::target
376
377	#[test]
378	fn volume_mount_short_two_parts_returns_second() {
379		let m = VolumeMount::Short("./data:/app/data".to_string());
380		assert_eq!(m.target(), "/app/data");
381	}
382
383	#[test]
384	fn volume_mount_short_three_parts_returns_second() {
385		let m = VolumeMount::Short("./data:/app/data:ro".to_string());
386		assert_eq!(m.target(), "/app/data");
387	}
388
389	#[test]
390	fn volume_mount_short_no_colon_returns_whole_string() {
391		let m = VolumeMount::Short("/app/data".to_string());
392		assert_eq!(m.target(), "/app/data");
393	}
394
395	#[test]
396	fn volume_mount_long_returns_target_field() {
397		let m = VolumeMount::Long {
398			volume_type: VolumeType::Bind,
399			source: Some("/host/path".to_string()),
400			target: "/container/path".to_string(),
401			read_only: None,
402			bind: None,
403			volume: None,
404			tmpfs: None,
405			consistency: None,
406		};
407		assert_eq!(m.target(), "/container/path");
408	}
409
410	// ServiceConfigRef
411
412	#[test]
413	fn config_ref_short_source() {
414		let r = ServiceConfigRef::Short("my-config".to_string());
415		assert_eq!(r.source(), "my-config");
416		assert!(r.target().is_none());
417	}
418
419	#[test]
420	fn config_ref_long_source_and_target() {
421		let r = ServiceConfigRef::Long {
422			source: "my-config".to_string(),
423			target: Some("/run/configs/my-config".to_string()),
424			uid: None,
425			gid: None,
426			mode: None,
427		};
428		assert_eq!(r.source(), "my-config");
429		assert_eq!(r.target(), Some("/run/configs/my-config"));
430	}
431
432	#[test]
433	fn config_ref_long_no_target() {
434		let r = ServiceConfigRef::Long {
435			source: "my-config".to_string(),
436			target: None,
437			uid: None,
438			gid: None,
439			mode: None,
440		};
441		assert!(r.target().is_none());
442	}
443
444	// ServiceSecretRef
445
446	#[test]
447	fn secret_ref_short_source() {
448		let r = ServiceSecretRef::Short("my-secret".to_string());
449		assert_eq!(r.source(), "my-secret");
450		assert!(r.target().is_none());
451	}
452
453	#[test]
454	fn secret_ref_long_source_and_target() {
455		let r = ServiceSecretRef::Long {
456			source: "my-secret".to_string(),
457			target: Some("/run/secrets/my-secret".to_string()),
458			uid: None,
459			gid: None,
460			mode: None,
461		};
462		assert_eq!(r.source(), "my-secret");
463		assert_eq!(r.target(), Some("/run/secrets/my-secret"));
464	}
465
466	#[test]
467	fn secret_ref_long_no_target() {
468		let r = ServiceSecretRef::Long {
469			source: "my-secret".to_string(),
470			target: None,
471			uid: None,
472			gid: None,
473			mode: None,
474		};
475		assert_eq!(r.source(), "my-secret");
476		assert!(r.target().is_none());
477	}
478}