Skip to main content

compose_lens/model/
volume.rs

1//! Typed service-volume mounts that preserve their authored syntax form.
2
3use super::{BooleanValue, FieldReference, Located};
4use crate::source::SourceSpan;
5
6/// The authored Compose syntax form of a service-volume mount.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum VolumeSyntax {
9    /// A colon-delimited scalar such as `./data:/var/lib/data:Z`.
10    Short,
11    /// A mapping with fields such as `type`, `source`, and `target`.
12    Long,
13}
14
15/// A requested `SELinux` relabel mode.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum SelinuxRelabel {
18    /// Shared relabeling, spelled `z`.
19    Shared,
20    /// Private relabeling, spelled `Z`.
21    Private,
22}
23
24/// A container-side mount path classified independently of the host operating system.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ContainerPath {
27    raw: String,
28    kind: ContainerPathKind,
29}
30
31impl ContainerPath {
32    fn parse(raw: String) -> Self {
33        let kind = if raw.starts_with('/') {
34            ContainerPathKind::UnixAbsolute
35        } else if is_windows_drive_absolute(&raw) {
36            ContainerPathKind::WindowsDriveAbsolute
37        } else if raw.starts_with(r"\\") || raw.starts_with("//") {
38            ContainerPathKind::WindowsUnc
39        } else if raw.contains('$') {
40            ContainerPathKind::Deferred
41        } else {
42            ContainerPathKind::Relative
43        };
44        Self { raw, kind }
45    }
46
47    /// Returns the target path without changing separators or spelling.
48    #[must_use]
49    pub fn raw(&self) -> &str {
50        &self.raw
51    }
52
53    /// Returns the container-platform lexical path kind.
54    #[must_use]
55    pub const fn kind(&self) -> ContainerPathKind {
56        self.kind
57    }
58}
59
60/// The lexical family of a container-side path.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum ContainerPathKind {
63    /// A `/`-prefixed Unix container path.
64    UnixAbsolute,
65    /// A drive-letter Windows container path.
66    WindowsDriveAbsolute,
67    /// A Windows UNC container path.
68    WindowsUnc,
69    /// A relative path retained for later validation.
70    Relative,
71    /// A path whose shape depends on interpolation.
72    Deferred,
73}
74
75/// A long-syntax service-volume mount type.
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub enum MountType {
78    /// A named or anonymous container volume.
79    Volume,
80    /// A host bind mount.
81    Bind,
82    /// An in-memory temporary filesystem.
83    Tmpfs,
84    /// A Windows named pipe.
85    NamedPipe,
86    /// An image-backed mount.
87    Image,
88    /// A cluster-managed mount.
89    Cluster,
90    /// A value not recognized by this `ComposeLens` release.
91    Other(String),
92}
93
94impl MountType {
95    pub(crate) fn from_text(value: String) -> Self {
96        match value.as_str() {
97            "volume" => Self::Volume,
98            "bind" => Self::Bind,
99            "tmpfs" => Self::Tmpfs,
100            "npipe" => Self::NamedPipe,
101            "image" => Self::Image,
102            "cluster" => Self::Cluster,
103            _ => Self::Other(value),
104        }
105    }
106}
107
108/// A short-syntax service-volume mount.
109///
110/// `source`, `target`, and `options` are a conservative decomposition for common Compose
111/// strings. [`Self::raw`] remains authoritative because platform-specific path grammars and
112/// implementation extensions can make a short string ambiguous.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ShortVolumeMount {
115    raw: Located<String>,
116    source: Option<String>,
117    target: Option<String>,
118    target_path: Option<ContainerPath>,
119    options: Vec<String>,
120}
121
122impl ShortVolumeMount {
123    pub(crate) fn new(raw: Located<String>) -> Self {
124        let (source, target, options) = split_short_volume(raw.value());
125        Self {
126            raw,
127            source,
128            target_path: target.as_ref().map(|value| ContainerPath::parse(value.clone())),
129            target,
130            options,
131        }
132    }
133
134    /// Returns the unquoted semantic scalar and its source span.
135    #[must_use]
136    pub const fn raw(&self) -> &Located<String> {
137        &self.raw
138    }
139
140    /// Returns the conservatively parsed source, if one was present.
141    #[must_use]
142    pub fn source(&self) -> Option<&str> {
143        self.source.as_deref()
144    }
145
146    /// Returns the conservatively parsed container target.
147    #[must_use]
148    pub fn target(&self) -> Option<&str> {
149        self.target.as_deref()
150    }
151
152    /// Returns the target classified using container-platform lexical rules.
153    ///
154    /// This does not consult the host operating system, so `/project/node_modules` remains an
155    /// anonymous Unix-container target even when `ComposeLens` runs on Windows.
156    #[must_use]
157    pub const fn target_path(&self) -> Option<&ContainerPath> {
158        self.target_path.as_ref()
159    }
160
161    /// Returns access-mode tokens in authored order.
162    #[must_use]
163    pub fn options(&self) -> &[String] {
164        &self.options
165    }
166
167    /// Returns the requested `SELinux` relabel mode, if present.
168    #[must_use]
169    pub fn selinux_relabel(&self) -> Option<SelinuxRelabel> {
170        self.options.iter().find_map(|option| match option.as_str() {
171            "z" => Some(SelinuxRelabel::Shared),
172            "Z" => Some(SelinuxRelabel::Private),
173            _ => None,
174        })
175    }
176}
177
178/// Bind-specific options in a long-syntax mount.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct BindOptions {
181    span: SourceSpan,
182    propagation: Option<Located<String>>,
183    create_host_path: Option<Located<BooleanValue>>,
184    selinux: Option<Located<SelinuxRelabel>>,
185    extension_fields: Vec<FieldReference>,
186    unknown_fields: Vec<FieldReference>,
187}
188
189impl BindOptions {
190    pub(crate) fn new(span: SourceSpan) -> Self {
191        Self {
192            span,
193            propagation: None,
194            create_host_path: None,
195            selinux: None,
196            extension_fields: Vec::new(),
197            unknown_fields: Vec::new(),
198        }
199    }
200
201    pub(crate) fn set_propagation(&mut self, value: Located<String>) {
202        self.propagation = Some(value);
203    }
204
205    pub(crate) fn set_create_host_path(&mut self, value: Located<BooleanValue>) {
206        self.create_host_path = Some(value);
207    }
208
209    pub(crate) fn set_selinux(&mut self, value: Located<SelinuxRelabel>) {
210        self.selinux = Some(value);
211    }
212
213    pub(super) fn push_extension(&mut self, field: FieldReference) {
214        self.extension_fields.push(field);
215    }
216
217    pub(super) fn push_unknown(&mut self, field: FieldReference) {
218        self.unknown_fields.push(field);
219    }
220
221    /// Returns the complete `bind` mapping span.
222    #[must_use]
223    pub const fn span(&self) -> SourceSpan {
224        self.span
225    }
226
227    /// Returns the requested bind-propagation mode.
228    #[must_use]
229    pub const fn propagation(&self) -> Option<&Located<String>> {
230        self.propagation.as_ref()
231    }
232
233    /// Returns the explicitly authored host-path creation setting.
234    ///
235    /// `None` means the field was omitted; it is deliberately not replaced by an
236    /// implementation default during typed parsing.
237    #[must_use]
238    pub const fn create_host_path(&self) -> Option<&Located<BooleanValue>> {
239        self.create_host_path.as_ref()
240    }
241
242    /// Returns the requested `SELinux` relabel mode.
243    #[must_use]
244    pub const fn selinux(&self) -> Option<&Located<SelinuxRelabel>> {
245        self.selinux.as_ref()
246    }
247
248    /// Returns `x-` extension fields retained from the bind mapping.
249    #[must_use]
250    pub fn extension_fields(&self) -> &[FieldReference] {
251        &self.extension_fields
252    }
253
254    /// Returns unrecognized bind fields retained from the source.
255    #[must_use]
256    pub fn unknown_fields(&self) -> &[FieldReference] {
257        &self.unknown_fields
258    }
259}
260
261/// A long-syntax service-volume mount.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct LongVolumeMount {
264    span: SourceSpan,
265    mount_type: Option<Located<MountType>>,
266    source: Option<Located<String>>,
267    target: Option<Located<String>>,
268    target_path: Option<Located<ContainerPath>>,
269    read_only: Option<Located<BooleanValue>>,
270    bind: Option<BindOptions>,
271    extension_fields: Vec<FieldReference>,
272    unknown_fields: Vec<FieldReference>,
273}
274
275impl LongVolumeMount {
276    pub(crate) fn new(span: SourceSpan) -> Self {
277        Self {
278            span,
279            mount_type: None,
280            source: None,
281            target: None,
282            target_path: None,
283            read_only: None,
284            bind: None,
285            extension_fields: Vec::new(),
286            unknown_fields: Vec::new(),
287        }
288    }
289
290    pub(crate) fn set_mount_type(&mut self, value: Located<MountType>) {
291        self.mount_type = Some(value);
292    }
293
294    pub(crate) fn set_source(&mut self, value: Located<String>) {
295        self.source = Some(value);
296    }
297
298    pub(crate) fn set_target(&mut self, value: Located<String>) {
299        self.target_path = Some(Located::new(ContainerPath::parse(value.value.clone()), value.span));
300        self.target = Some(value);
301    }
302
303    pub(crate) fn set_read_only(&mut self, value: Located<BooleanValue>) {
304        self.read_only = Some(value);
305    }
306
307    pub(crate) fn set_bind(&mut self, value: BindOptions) {
308        self.bind = Some(value);
309    }
310
311    pub(super) fn push_extension(&mut self, field: FieldReference) {
312        self.extension_fields.push(field);
313    }
314
315    pub(super) fn push_unknown(&mut self, field: FieldReference) {
316        self.unknown_fields.push(field);
317    }
318
319    /// Returns the complete mapping span.
320    #[must_use]
321    pub const fn span(&self) -> SourceSpan {
322        self.span
323    }
324
325    /// Returns the explicitly authored mount type.
326    #[must_use]
327    pub const fn mount_type(&self) -> Option<&Located<MountType>> {
328        self.mount_type.as_ref()
329    }
330
331    /// Returns the mount source.
332    #[must_use]
333    pub const fn source(&self) -> Option<&Located<String>> {
334        self.source.as_ref()
335    }
336
337    /// Returns the container target.
338    #[must_use]
339    pub const fn target(&self) -> Option<&Located<String>> {
340        self.target.as_ref()
341    }
342
343    /// Returns the target classified using container-platform lexical rules.
344    #[must_use]
345    pub const fn target_path(&self) -> Option<&Located<ContainerPath>> {
346        self.target_path.as_ref()
347    }
348
349    /// Returns the explicitly authored read-only setting.
350    #[must_use]
351    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
352        self.read_only.as_ref()
353    }
354
355    /// Returns long-syntax bind options.
356    #[must_use]
357    pub const fn bind(&self) -> Option<&BindOptions> {
358        self.bind.as_ref()
359    }
360
361    /// Returns `x-` extension fields retained from the mount mapping.
362    #[must_use]
363    pub fn extension_fields(&self) -> &[FieldReference] {
364        &self.extension_fields
365    }
366
367    /// Returns recognized-by-Compose but not-yet-typed and unrecognized fields.
368    #[must_use]
369    pub fn unknown_fields(&self) -> &[FieldReference] {
370        &self.unknown_fields
371    }
372}
373
374/// A service-volume mount with its authored syntax form retained.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub enum VolumeMount {
377    /// Colon-delimited short syntax.
378    Short(ShortVolumeMount),
379    /// Mapping-based long syntax.
380    Long(Box<LongVolumeMount>),
381}
382
383impl VolumeMount {
384    /// Returns the authored syntax form.
385    #[must_use]
386    pub const fn syntax(&self) -> VolumeSyntax {
387        match self {
388            Self::Short(_) => VolumeSyntax::Short,
389            Self::Long(_) => VolumeSyntax::Long,
390        }
391    }
392
393    /// Returns the complete source span of the mount value.
394    #[must_use]
395    pub const fn span(&self) -> SourceSpan {
396        match self {
397            Self::Short(value) => value.raw().span(),
398            Self::Long(value) => value.span(),
399        }
400    }
401
402    /// Returns the requested `SELinux` relabel mode without erasing syntax provenance.
403    #[must_use]
404    pub fn selinux_relabel(&self) -> Option<SelinuxRelabel> {
405        match self {
406            Self::Short(value) => value.selinux_relabel(),
407            Self::Long(value) => value.bind()?.selinux().map(|mode| *mode.value()),
408        }
409    }
410}
411
412fn split_short_volume(value: &str) -> (Option<String>, Option<String>, Vec<String>) {
413    let fields = split_colon_fields(value);
414    match fields.as_slice() {
415        [] => (None, None, Vec::new()),
416        [target] => (None, Some((*target).to_owned()), Vec::new()),
417        [source, target] => (Some((*source).to_owned()), Some((*target).to_owned()), Vec::new()),
418        [source, middle @ .., options] => (
419            Some((*source).to_owned()),
420            Some(middle.join(":")),
421            options.split(',').map(str::to_owned).collect(),
422        ),
423    }
424}
425
426fn split_colon_fields(value: &str) -> Vec<&str> {
427    let mut fields = Vec::new();
428    let mut start = 0;
429    for (index, character) in value.char_indices() {
430        if character != ':' || is_drive_separator(value, start, index) {
431            continue;
432        }
433        fields.push(&value[start..index]);
434        start = index + character.len_utf8();
435    }
436    fields.push(&value[start..]);
437    fields
438}
439
440fn is_drive_separator(value: &str, field_start: usize, colon_index: usize) -> bool {
441    let field = &value[field_start..colon_index];
442    let next = value[colon_index + 1..].chars().next();
443    field.len() == 1 && field.as_bytes()[0].is_ascii_alphabetic() && matches!(next, Some('/' | '\\'))
444}
445
446fn is_windows_drive_absolute(value: &str) -> bool {
447    value.as_bytes().get(1) == Some(&b':')
448        && value.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
449        && matches!(value.as_bytes().get(2), Some(b'/' | b'\\'))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::{ContainerPathKind, ShortVolumeMount, split_short_volume};
455    use crate::model::Located;
456    use crate::source::{SourceId, SourceSpan};
457
458    #[test]
459    fn conservatively_splits_linux_and_windows_short_mounts() {
460        assert_eq!(
461            split_short_volume("./data:/var/lib/data:Z,ro"),
462            (
463                Some("./data".to_owned()),
464                Some("/var/lib/data".to_owned()),
465                vec!["Z".to_owned(), "ro".to_owned()]
466            )
467        );
468        assert_eq!(
469            split_short_volume(r"C:\data:/var/lib/data:z"),
470            (
471                Some(r"C:\data".to_owned()),
472                Some("/var/lib/data".to_owned()),
473                vec!["z".to_owned()]
474            )
475        );
476        assert_eq!(
477            split_short_volume("cache:/cache"),
478            (Some("cache".to_owned()), Some("/cache".to_owned()), Vec::new())
479        );
480    }
481
482    #[test]
483    fn classifies_anonymous_targets_without_host_path_apis() -> Result<(), &'static str> {
484        let raw = "/project/node_modules";
485        let span = SourceSpan::new(SourceId::new(1), 0, raw.len()).ok_or("valid test span expected")?;
486        let mount = ShortVolumeMount::new(Located::new(raw.to_owned(), span));
487        assert_eq!(mount.source(), None);
488        assert_eq!(
489            mount.target_path().map(super::ContainerPath::kind),
490            Some(ContainerPathKind::UnixAbsolute)
491        );
492        Ok(())
493    }
494}