Skip to main content

compose_lens/model/
volume.rs

1//! Typed service-volume mounts that preserve their authored syntax form.
2
3use super::{BooleanValue, ComposeScalar, FieldReference, Labels, 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    recursive: Option<Located<String>>,
186    extension_fields: Vec<FieldReference>,
187    unknown_fields: Vec<FieldReference>,
188}
189
190impl BindOptions {
191    pub(crate) fn new(span: SourceSpan) -> Self {
192        Self {
193            span,
194            propagation: None,
195            create_host_path: None,
196            selinux: None,
197            recursive: None,
198            extension_fields: Vec::new(),
199            unknown_fields: Vec::new(),
200        }
201    }
202
203    pub(crate) fn set_propagation(&mut self, value: Located<String>) {
204        self.propagation = Some(value);
205    }
206
207    pub(crate) fn set_create_host_path(&mut self, value: Located<BooleanValue>) {
208        self.create_host_path = Some(value);
209    }
210
211    pub(crate) fn set_selinux(&mut self, value: Located<SelinuxRelabel>) {
212        self.selinux = Some(value);
213    }
214
215    pub(crate) fn set_recursive(&mut self, value: Located<String>) {
216        self.recursive = Some(value);
217    }
218
219    pub(crate) fn push_extension(&mut self, field: FieldReference) {
220        self.extension_fields.push(field);
221    }
222
223    pub(crate) fn push_unknown(&mut self, field: FieldReference) {
224        self.unknown_fields.push(field);
225    }
226
227    /// Returns the complete `bind` mapping span.
228    #[must_use]
229    pub const fn span(&self) -> SourceSpan {
230        self.span
231    }
232
233    /// Returns the requested bind-propagation mode.
234    #[must_use]
235    pub const fn propagation(&self) -> Option<&Located<String>> {
236        self.propagation.as_ref()
237    }
238
239    /// Returns the explicitly authored host-path creation setting.
240    ///
241    /// `None` means the field was omitted; it is deliberately not replaced by an
242    /// implementation default during typed parsing.
243    #[must_use]
244    pub const fn create_host_path(&self) -> Option<&Located<BooleanValue>> {
245        self.create_host_path.as_ref()
246    }
247
248    /// Returns the requested `SELinux` relabel mode.
249    #[must_use]
250    pub const fn selinux(&self) -> Option<&Located<SelinuxRelabel>> {
251        self.selinux.as_ref()
252    }
253
254    /// Returns the authored recursive bind mode without interpreting host mount behavior.
255    #[must_use]
256    pub const fn recursive(&self) -> Option<&Located<String>> {
257        self.recursive.as_ref()
258    }
259
260    /// Returns `x-` extension fields retained from the bind mapping.
261    #[must_use]
262    pub fn extension_fields(&self) -> &[FieldReference] {
263        &self.extension_fields
264    }
265
266    /// Returns unrecognized bind fields retained from the source.
267    #[must_use]
268    pub fn unknown_fields(&self) -> &[FieldReference] {
269        &self.unknown_fields
270    }
271}
272
273/// Image-specific options in a long-syntax service-volume mount.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct ImageMountOptions {
276    span: SourceSpan,
277    subpath: Option<Located<String>>,
278    extension_fields: Vec<FieldReference>,
279    unknown_fields: Vec<FieldReference>,
280}
281
282impl ImageMountOptions {
283    pub(crate) fn new(span: SourceSpan) -> Self {
284        Self {
285            span,
286            subpath: None,
287            extension_fields: Vec::new(),
288            unknown_fields: Vec::new(),
289        }
290    }
291    pub(crate) fn set_subpath(&mut self, value: Located<String>) {
292        self.subpath = Some(value);
293    }
294    pub(crate) fn push_extension(&mut self, field: FieldReference) {
295        self.extension_fields.push(field);
296    }
297    pub(crate) fn push_unknown(&mut self, field: FieldReference) {
298        self.unknown_fields.push(field);
299    }
300    /// Returns the complete `image` mapping span.
301    #[must_use]
302    pub const fn span(&self) -> SourceSpan {
303        self.span
304    }
305    /// Returns the authored image subpath without resolving an image or filesystem.
306    #[must_use]
307    pub const fn subpath(&self) -> Option<&Located<String>> {
308        self.subpath.as_ref()
309    }
310    /// Returns retained `x-` image fields.
311    #[must_use]
312    pub fn extension_fields(&self) -> &[FieldReference] {
313        &self.extension_fields
314    }
315    /// Returns unrecognized or malformed image fields retained from source.
316    #[must_use]
317    pub fn unknown_fields(&self) -> &[FieldReference] {
318        &self.unknown_fields
319    }
320}
321
322/// Tmpfs-specific options in a long-syntax service-volume mount.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct TmpfsMountOptions {
325    span: SourceSpan,
326    size: Option<Located<ComposeScalar>>,
327    mode: Option<Located<ComposeScalar>>,
328    extension_fields: Vec<FieldReference>,
329    unknown_fields: Vec<FieldReference>,
330}
331
332impl TmpfsMountOptions {
333    pub(crate) fn new(span: SourceSpan) -> Self {
334        Self {
335            span,
336            size: None,
337            mode: None,
338            extension_fields: Vec::new(),
339            unknown_fields: Vec::new(),
340        }
341    }
342    pub(crate) fn set_size(&mut self, value: Located<ComposeScalar>) {
343        self.size = Some(value);
344    }
345    pub(crate) fn set_mode(&mut self, value: Located<ComposeScalar>) {
346        self.mode = Some(value);
347    }
348    pub(crate) fn push_extension(&mut self, field: FieldReference) {
349        self.extension_fields.push(field);
350    }
351    pub(crate) fn push_unknown(&mut self, field: FieldReference) {
352        self.unknown_fields.push(field);
353    }
354    /// Returns the complete `tmpfs` mapping span.
355    #[must_use]
356    pub const fn span(&self) -> SourceSpan {
357        self.span
358    }
359    /// Returns the raw size scalar without parsing units or consulting runtime defaults.
360    #[must_use]
361    pub const fn size(&self) -> Option<&Located<ComposeScalar>> {
362        self.size.as_ref()
363    }
364    /// Returns the raw mode scalar without interpreting permission bits.
365    #[must_use]
366    pub const fn mode(&self) -> Option<&Located<ComposeScalar>> {
367        self.mode.as_ref()
368    }
369    /// Returns retained `x-` tmpfs fields.
370    #[must_use]
371    pub fn extension_fields(&self) -> &[FieldReference] {
372        &self.extension_fields
373    }
374    /// Returns unrecognized or malformed tmpfs fields retained from source.
375    #[must_use]
376    pub fn unknown_fields(&self) -> &[FieldReference] {
377        &self.unknown_fields
378    }
379}
380
381/// Volume-specific options in a long-syntax service-volume mount.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct VolumeMountOptions {
384    span: SourceSpan,
385    nocopy: Option<Located<BooleanValue>>,
386    subpath: Option<Located<String>>,
387    labels: Option<Labels>,
388    extension_fields: Vec<FieldReference>,
389    unknown_fields: Vec<FieldReference>,
390}
391
392impl VolumeMountOptions {
393    pub(crate) fn new(span: SourceSpan) -> Self {
394        Self {
395            span,
396            nocopy: None,
397            subpath: None,
398            labels: None,
399            extension_fields: Vec::new(),
400            unknown_fields: Vec::new(),
401        }
402    }
403    pub(crate) fn set_nocopy(&mut self, value: Located<BooleanValue>) {
404        self.nocopy = Some(value);
405    }
406    pub(crate) fn set_subpath(&mut self, value: Located<String>) {
407        self.subpath = Some(value);
408    }
409    pub(crate) fn set_labels(&mut self, value: Labels) {
410        self.labels = Some(value);
411    }
412    pub(crate) fn push_extension(&mut self, field: FieldReference) {
413        self.extension_fields.push(field);
414    }
415    pub(crate) fn push_unknown(&mut self, field: FieldReference) {
416        self.unknown_fields.push(field);
417    }
418    /// Returns the complete `volume` mapping span.
419    #[must_use]
420    pub const fn span(&self) -> SourceSpan {
421        self.span
422    }
423    /// Returns the explicit copy-prevention setting without inferring a runtime default.
424    #[must_use]
425    pub const fn nocopy(&self) -> Option<&Located<BooleanValue>> {
426        self.nocopy.as_ref()
427    }
428    /// Returns the authored named-volume subpath without resolving a volume or filesystem.
429    #[must_use]
430    pub const fn subpath(&self) -> Option<&Located<String>> {
431        self.subpath.as_ref()
432    }
433    /// Returns named-volume labels in their authored list or mapping form.
434    #[must_use]
435    pub const fn labels(&self) -> Option<&Labels> {
436        self.labels.as_ref()
437    }
438    /// Returns retained `x-` volume-option fields.
439    #[must_use]
440    pub fn extension_fields(&self) -> &[FieldReference] {
441        &self.extension_fields
442    }
443    /// Returns unrecognized or malformed volume-option fields retained from source.
444    #[must_use]
445    pub fn unknown_fields(&self) -> &[FieldReference] {
446        &self.unknown_fields
447    }
448}
449
450/// A long-syntax service-volume mount.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct LongVolumeMount {
453    span: SourceSpan,
454    mount_type: Option<Located<MountType>>,
455    source: Option<Located<String>>,
456    target: Option<Located<String>>,
457    target_path: Option<Located<ContainerPath>>,
458    read_only: Option<Located<BooleanValue>>,
459    consistency: Option<Located<String>>,
460    bind: Option<BindOptions>,
461    image: Option<ImageMountOptions>,
462    tmpfs: Option<TmpfsMountOptions>,
463    volume: Option<VolumeMountOptions>,
464    extension_fields: Vec<FieldReference>,
465    unknown_fields: Vec<FieldReference>,
466}
467
468impl LongVolumeMount {
469    pub(crate) fn new(span: SourceSpan) -> Self {
470        Self {
471            span,
472            mount_type: None,
473            source: None,
474            target: None,
475            target_path: None,
476            read_only: None,
477            consistency: None,
478            bind: None,
479            image: None,
480            tmpfs: None,
481            volume: None,
482            extension_fields: Vec::new(),
483            unknown_fields: Vec::new(),
484        }
485    }
486
487    pub(crate) fn set_mount_type(&mut self, value: Located<MountType>) {
488        self.mount_type = Some(value);
489    }
490
491    pub(crate) fn set_source(&mut self, value: Located<String>) {
492        self.source = Some(value);
493    }
494
495    pub(crate) fn set_target(&mut self, value: Located<String>) {
496        self.target_path = Some(Located::new(ContainerPath::parse(value.value.clone()), value.span));
497        self.target = Some(value);
498    }
499
500    pub(crate) fn set_read_only(&mut self, value: Located<BooleanValue>) {
501        self.read_only = Some(value);
502    }
503
504    pub(crate) fn set_consistency(&mut self, value: Located<String>) {
505        self.consistency = Some(value);
506    }
507
508    pub(crate) fn set_bind(&mut self, value: BindOptions) {
509        self.bind = Some(value);
510    }
511    pub(crate) fn set_image(&mut self, value: ImageMountOptions) {
512        self.image = Some(value);
513    }
514    pub(crate) fn set_tmpfs(&mut self, value: TmpfsMountOptions) {
515        self.tmpfs = Some(value);
516    }
517    pub(crate) fn set_volume(&mut self, value: VolumeMountOptions) {
518        self.volume = Some(value);
519    }
520
521    pub(crate) fn push_extension(&mut self, field: FieldReference) {
522        self.extension_fields.push(field);
523    }
524
525    pub(crate) fn push_unknown(&mut self, field: FieldReference) {
526        self.unknown_fields.push(field);
527    }
528
529    /// Returns the complete mapping span.
530    #[must_use]
531    pub const fn span(&self) -> SourceSpan {
532        self.span
533    }
534
535    /// Returns the explicitly authored mount type.
536    #[must_use]
537    pub const fn mount_type(&self) -> Option<&Located<MountType>> {
538        self.mount_type.as_ref()
539    }
540
541    /// Returns the mount source.
542    #[must_use]
543    pub const fn source(&self) -> Option<&Located<String>> {
544        self.source.as_ref()
545    }
546
547    /// Returns the container target.
548    #[must_use]
549    pub const fn target(&self) -> Option<&Located<String>> {
550        self.target.as_ref()
551    }
552
553    /// Returns the target classified using container-platform lexical rules.
554    #[must_use]
555    pub const fn target_path(&self) -> Option<&Located<ContainerPath>> {
556        self.target_path.as_ref()
557    }
558
559    /// Returns the explicitly authored read-only setting.
560    #[must_use]
561    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
562        self.read_only.as_ref()
563    }
564
565    /// Returns the authored consistency token without provider/default interpretation.
566    #[must_use]
567    pub const fn consistency(&self) -> Option<&Located<String>> {
568        self.consistency.as_ref()
569    }
570
571    /// Returns long-syntax bind options.
572    #[must_use]
573    pub const fn bind(&self) -> Option<&BindOptions> {
574        self.bind.as_ref()
575    }
576
577    /// Returns long-syntax image-mount options.
578    #[must_use]
579    pub const fn image(&self) -> Option<&ImageMountOptions> {
580        self.image.as_ref()
581    }
582    /// Returns long-syntax tmpfs-mount options.
583    #[must_use]
584    pub const fn tmpfs(&self) -> Option<&TmpfsMountOptions> {
585        self.tmpfs.as_ref()
586    }
587    /// Returns long-syntax named-volume options.
588    #[must_use]
589    pub const fn volume(&self) -> Option<&VolumeMountOptions> {
590        self.volume.as_ref()
591    }
592
593    /// Returns `x-` extension fields retained from the mount mapping.
594    #[must_use]
595    pub fn extension_fields(&self) -> &[FieldReference] {
596        &self.extension_fields
597    }
598
599    /// Returns recognized-by-Compose but not-yet-typed and unrecognized fields.
600    #[must_use]
601    pub fn unknown_fields(&self) -> &[FieldReference] {
602        &self.unknown_fields
603    }
604}
605
606/// A service-volume mount with its authored syntax form retained.
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub enum VolumeMount {
609    /// Colon-delimited short syntax.
610    Short(ShortVolumeMount),
611    /// Mapping-based long syntax.
612    Long(Box<LongVolumeMount>),
613}
614
615impl VolumeMount {
616    /// Returns the authored syntax form.
617    #[must_use]
618    pub const fn syntax(&self) -> VolumeSyntax {
619        match self {
620            Self::Short(_) => VolumeSyntax::Short,
621            Self::Long(_) => VolumeSyntax::Long,
622        }
623    }
624
625    /// Returns the complete source span of the mount value.
626    #[must_use]
627    pub const fn span(&self) -> SourceSpan {
628        match self {
629            Self::Short(value) => value.raw().span(),
630            Self::Long(value) => value.span(),
631        }
632    }
633
634    /// Returns the requested `SELinux` relabel mode without erasing syntax provenance.
635    #[must_use]
636    pub fn selinux_relabel(&self) -> Option<SelinuxRelabel> {
637        match self {
638            Self::Short(value) => value.selinux_relabel(),
639            Self::Long(value) => value.bind()?.selinux().map(|mode| *mode.value()),
640        }
641    }
642}
643
644fn split_short_volume(value: &str) -> (Option<String>, Option<String>, Vec<String>) {
645    let fields = split_colon_fields(value);
646    match fields.as_slice() {
647        [] => (None, None, Vec::new()),
648        [target] => (None, Some((*target).to_owned()), Vec::new()),
649        [source, target] => (Some((*source).to_owned()), Some((*target).to_owned()), Vec::new()),
650        [source, middle @ .., options] => (
651            Some((*source).to_owned()),
652            Some(middle.join(":")),
653            options.split(',').map(str::to_owned).collect(),
654        ),
655    }
656}
657
658fn split_colon_fields(value: &str) -> Vec<&str> {
659    let mut fields = Vec::new();
660    let mut start = 0;
661    for (index, character) in value.char_indices() {
662        if character != ':' || is_drive_separator(value, start, index) {
663            continue;
664        }
665        fields.push(&value[start..index]);
666        start = index + character.len_utf8();
667    }
668    fields.push(&value[start..]);
669    fields
670}
671
672fn is_drive_separator(value: &str, field_start: usize, colon_index: usize) -> bool {
673    let field = &value[field_start..colon_index];
674    let next = value[colon_index + 1..].chars().next();
675    field.len() == 1 && field.as_bytes()[0].is_ascii_alphabetic() && matches!(next, Some('/' | '\\'))
676}
677
678fn is_windows_drive_absolute(value: &str) -> bool {
679    value.as_bytes().get(1) == Some(&b':')
680        && value.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
681        && matches!(value.as_bytes().get(2), Some(b'/' | b'\\'))
682}
683
684#[cfg(test)]
685mod tests {
686    use super::{ContainerPathKind, ShortVolumeMount, split_short_volume};
687    use crate::model::Located;
688    use crate::source::{SourceId, SourceSpan};
689
690    #[test]
691    fn conservatively_splits_linux_and_windows_short_mounts() {
692        assert_eq!(
693            split_short_volume("./data:/var/lib/data:Z,ro"),
694            (
695                Some("./data".to_owned()),
696                Some("/var/lib/data".to_owned()),
697                vec!["Z".to_owned(), "ro".to_owned()]
698            )
699        );
700        assert_eq!(
701            split_short_volume(r"C:\data:/var/lib/data:z"),
702            (
703                Some(r"C:\data".to_owned()),
704                Some("/var/lib/data".to_owned()),
705                vec!["z".to_owned()]
706            )
707        );
708        assert_eq!(
709            split_short_volume("cache:/cache"),
710            (Some("cache".to_owned()), Some("/cache".to_owned()), Vec::new())
711        );
712    }
713
714    #[test]
715    fn classifies_anonymous_targets_without_host_path_apis() -> Result<(), &'static str> {
716        let raw = "/project/node_modules";
717        let span = SourceSpan::new(SourceId::new(1), 0, raw.len()).ok_or("valid test span expected")?;
718        let mount = ShortVolumeMount::new(Located::new(raw.to_owned(), span));
719        assert_eq!(mount.source(), None);
720        assert_eq!(
721            mount.target_path().map(super::ContainerPath::kind),
722            Some(ContainerPathKind::UnixAbsolute)
723        );
724        Ok(())
725    }
726}