Skip to main content

compose_lens/resolution/
paths.rs

1use super::{selection_matches, service_entries, service_in_scope};
2use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
3use crate::loader::{
4    IncludeCompositionResult, IncludeDefinitionEvidence, IncludeIdentity, IncludeProjectDirectoryPlan,
5};
6use crate::merge::{MergedProject, MergedScalar, MergedValue};
7use crate::model::{Located, MountType, ShortVolumeMount, VolumeMount};
8use crate::profiles::ProfileSelection;
9use crate::source::SourceSpan;
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13/// A home-relative path cannot be expanded without explicit caller context.
14pub const HOME_DIRECTORY_REQUIRED: DiagnosticCode = DiagnosticCode::new("compose.paths.home-directory-required");
15/// A selected included resource has no authorized occurrence base directory.
16pub const INCLUDE_RESOURCE_PATH_BASE_UNAVAILABLE: DiagnosticCode =
17    DiagnosticCode::new("compose.include.resource-path-base-unavailable");
18/// Included composition evidence and the supplied directory plan do not describe the same occurrence.
19pub const INCLUDE_RESOURCE_PATH_PLAN_MISMATCH: DiagnosticCode =
20    DiagnosticCode::new("compose.include.resource-path-plan-mismatch");
21
22/// The lexical category of one authored host path.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum HostPathKind {
25    /// A path interpreted relative to the project base directory.
26    Relative,
27    /// A Unix-style absolute path.
28    UnixAbsolute,
29    /// A Windows drive-letter absolute path.
30    WindowsDriveAbsolute,
31    /// A Windows UNC path.
32    WindowsUnc,
33    /// `~` or a `~/`-prefixed path requiring an explicit home directory.
34    HomeRelative,
35}
36
37/// Why a host path participates in the Compose project.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub enum PathPurpose {
40    /// A service bind-mount source.
41    ServiceBind {
42        /// Service name.
43        service: String,
44        /// Zero-based merged mount index.
45        index: usize,
46    },
47    /// A top-level config `file` source.
48    ConfigFile {
49        /// Config model name.
50        config: String,
51    },
52    /// A top-level secret `file` source.
53    SecretFile {
54        /// Secret model name.
55        secret: String,
56    },
57}
58
59/// Explicit context for host-path interpretation.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct PathContext {
62    home_directory: Option<PathBuf>,
63}
64
65impl PathContext {
66    /// Creates context without a home-directory assumption.
67    #[must_use]
68    pub const fn new() -> Self {
69        Self { home_directory: None }
70    }
71
72    /// Supplies a caller-owned home directory for `~` expansion.
73    #[must_use]
74    pub fn with_home_directory(mut self, directory: impl Into<PathBuf>) -> Self {
75        self.home_directory = Some(directory.into());
76        self
77    }
78
79    /// Returns the explicit home directory, if supplied.
80    #[must_use]
81    pub fn home_directory(&self) -> Option<&Path> {
82        self.home_directory.as_deref()
83    }
84}
85
86/// One classified host path and its explicit resolution origin.
87#[derive(Clone, PartialEq, Eq)]
88pub struct ResolvedHostPath {
89    raw: String,
90    kind: HostPathKind,
91    purpose: PathPurpose,
92    source: SourceSpan,
93    origin: PathBuf,
94    resolved: Option<PathBuf>,
95    sensitive: bool,
96}
97
98impl ResolvedHostPath {
99    /// Returns the interpolated but otherwise unmodified path value.
100    #[must_use]
101    pub fn raw(&self) -> &str {
102        &self.raw
103    }
104
105    /// Returns the lexical path category.
106    #[must_use]
107    pub const fn kind(&self) -> HostPathKind {
108        self.kind
109    }
110
111    /// Returns why the path is used.
112    #[must_use]
113    pub const fn purpose(&self) -> &PathPurpose {
114        &self.purpose
115    }
116
117    /// Returns the authored value span.
118    #[must_use]
119    pub const fn source(&self) -> SourceSpan {
120        self.source
121    }
122
123    /// Returns the first-file project directory used as the path origin.
124    #[must_use]
125    pub fn origin(&self) -> &Path {
126        &self.origin
127    }
128
129    /// Returns the path with its explicit relative or home origin applied.
130    ///
131    /// This is lexical resolution only. It does not canonicalize, follow symlinks, or access the
132    /// file system. Windows absolute paths remain representable on non-Windows hosts.
133    #[must_use]
134    pub fn resolved(&self) -> Option<&Path> {
135        self.resolved.as_deref()
136    }
137
138    /// Reports whether interpolation inserted sensitive content.
139    #[must_use]
140    pub const fn is_sensitive(&self) -> bool {
141        self.sensitive
142    }
143}
144
145impl fmt::Debug for ResolvedHostPath {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        let raw = if self.sensitive { "<redacted>" } else { &self.raw };
148        let resolved = if self.sensitive { None } else { self.resolved.as_deref() };
149        formatter
150            .debug_struct("ResolvedHostPath")
151            .field("raw", &raw)
152            .field("kind", &self.kind)
153            .field("purpose", &self.purpose)
154            .field("source", &self.source)
155            .field("origin", &self.origin)
156            .field("resolved", &resolved)
157            .field("sensitive", &self.sensitive)
158            .finish()
159    }
160}
161
162/// A recoverable, non-destructive host-path resolution result.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct PathResolution {
165    paths: Vec<ResolvedHostPath>,
166    diagnostics: Vec<Diagnostic>,
167}
168
169impl PathResolution {
170    /// Returns paths in deterministic project traversal order.
171    #[must_use]
172    pub fn paths(&self) -> &[ResolvedHostPath] {
173        &self.paths
174    }
175
176    /// Returns path diagnostics.
177    #[must_use]
178    pub fn diagnostics(&self) -> &[Diagnostic] {
179        &self.diagnostics
180    }
181
182    /// Reports whether path processing emitted no error diagnostics.
183    #[must_use]
184    pub fn is_valid(&self) -> bool {
185        self.diagnostics
186            .iter()
187            .all(|diagnostic| diagnostic.severity() != Severity::Error)
188    }
189}
190
191/// One selected included service bind, config, or secret file path and its occurrence-specific
192/// lexical result.
193#[derive(Clone, PartialEq, Eq)]
194pub struct IncludedResourcePath {
195    raw: String,
196    kind: HostPathKind,
197    purpose: PathPurpose,
198    source: SourceSpan,
199    occurrence_index: usize,
200    identity: IncludeIdentity,
201    base_directory: Option<PathBuf>,
202    resolved: Option<PathBuf>,
203}
204
205impl IncludedResourcePath {
206    /// Returns the authored, uninterpolated path value.
207    #[must_use]
208    pub fn raw(&self) -> &str {
209        &self.raw
210    }
211
212    /// Returns the lexical path category.
213    #[must_use]
214    pub const fn kind(&self) -> HostPathKind {
215        self.kind
216    }
217
218    /// Returns the selected resource and namespace that supplied this path.
219    #[must_use]
220    pub const fn purpose(&self) -> &PathPurpose {
221        &self.purpose
222    }
223
224    /// Returns the source span that anchors this path finding.
225    ///
226    /// Long-syntax bind sources and top-level config/secret `file` values retain the exact
227    /// authored value-scalar span. A short-syntax service bind is decoded from one colon-delimited
228    /// mount scalar, so its source component has no independently retained byte range; this getter
229    /// deliberately returns the containing authored mount-scalar span instead of guessing through
230    /// YAML quoting or escape spelling.
231    #[must_use]
232    pub const fn source(&self) -> SourceSpan {
233        self.source
234    }
235
236    /// Returns the retained include occurrence index.
237    #[must_use]
238    pub const fn occurrence_index(&self) -> usize {
239        self.occurrence_index
240    }
241
242    /// Returns the caller-defined identity of the retained occurrence.
243    #[must_use]
244    pub const fn identity(&self) -> &IncludeIdentity {
245        &self.identity
246    }
247
248    /// Returns the authorized occurrence base, when planning supplied one.
249    #[must_use]
250    pub fn base_directory(&self) -> Option<&Path> {
251        self.base_directory.as_deref()
252    }
253
254    /// Returns the lexical result, when its base and any required home directory were available.
255    #[must_use]
256    pub fn resolved(&self) -> Option<&Path> {
257        self.resolved.as_deref()
258    }
259}
260
261impl fmt::Debug for IncludedResourcePath {
262    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
263        formatter
264            .debug_struct("IncludedResourcePath")
265            .field("raw", &"<redacted-path>")
266            .field("kind", &self.kind)
267            .field("purpose", &self.purpose)
268            .field("source", &self.source)
269            .field("occurrence_index", &self.occurrence_index)
270            .field("identity", &"<redacted-identity>")
271            .field(
272                "base_directory",
273                &self.base_directory.as_ref().map(|_| "<authorized-directory>"),
274            )
275            .field("resolved", &self.resolved.as_ref().map(|_| "<resolved-path>"))
276            .finish()
277    }
278}
279
280/// Recoverable lexical resolution for selected included service binds, config, and secret file
281/// paths.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct IncludedResourcePathResolution {
284    paths: Vec<IncludedResourcePath>,
285    diagnostics: Vec<Diagnostic>,
286    upstream_complete: bool,
287}
288
289impl IncludedResourcePathResolution {
290    /// Returns selected paths in service-then-config-then-secret composition order.
291    #[must_use]
292    pub fn paths(&self) -> &[IncludedResourcePath] {
293        &self.paths
294    }
295
296    /// Returns composition, directory-plan, and lexical path diagnostics.
297    #[must_use]
298    pub fn diagnostics(&self) -> &[Diagnostic] {
299        &self.diagnostics
300    }
301
302    /// Reports whether no error diagnostic was retained or emitted.
303    #[must_use]
304    pub fn is_valid(&self) -> bool {
305        self.diagnostics
306            .iter()
307            .all(|diagnostic| diagnostic.severity() != Severity::Error)
308    }
309
310    /// Reports whether every selected file path resolved and no error occurred.
311    #[must_use]
312    pub fn is_complete(&self) -> bool {
313        self.upstream_complete && self.is_valid() && self.paths.iter().all(|path| path.resolved.is_some())
314    }
315}
316
317/// Lexically resolves selected included service bind sources and config and secret `file` paths.
318///
319/// This consumes only typed, authored composition values and caller-authorized occurrence bases.
320/// It considers short-form bind sources only when their spelling is already path-like and
321/// long-form sources only when `type: bind` is selected. It does not interpolate, canonicalize,
322/// access the file system, or resolve any other path family.
323#[must_use]
324pub fn resolve_included_resource_paths(
325    composition: &IncludeCompositionResult,
326    directory_plan: &IncludeProjectDirectoryPlan,
327    context: &PathContext,
328) -> IncludedResourcePathResolution {
329    let common_prefix = composition
330        .diagnostics()
331        .iter()
332        .zip(directory_plan.diagnostics())
333        .take_while(|(left, right)| left == right)
334        .count();
335    let mut diagnostics = composition.diagnostics().to_vec();
336    diagnostics.extend_from_slice(&directory_plan.diagnostics()[common_prefix..]);
337    let mut paths = Vec::new();
338
339    if let Some(root) = composition.root() {
340        for definition in root.services() {
341            let Some(volumes) = definition.definition().volumes() else {
342                continue;
343            };
344            for (index, mount) in volumes.value().iter().enumerate() {
345                let Some((raw, source)) = included_bind_source(mount.value()) else {
346                    continue;
347                };
348                push_included_resource_path(
349                    &mut paths,
350                    &mut diagnostics,
351                    directory_plan,
352                    context,
353                    definition.evidence(),
354                    raw,
355                    source,
356                    PathPurpose::ServiceBind {
357                        service: definition.name().to_owned(),
358                        index,
359                    },
360                );
361            }
362        }
363        for definition in root.configs() {
364            if let Some(file) = definition.definition().file() {
365                push_included_resource_path(
366                    &mut paths,
367                    &mut diagnostics,
368                    directory_plan,
369                    context,
370                    definition.evidence(),
371                    file.value(),
372                    file.span(),
373                    PathPurpose::ConfigFile {
374                        config: definition.name().to_owned(),
375                    },
376                );
377            }
378        }
379        for definition in root.secrets() {
380            if let Some(file) = definition.definition().file() {
381                push_included_resource_path(
382                    &mut paths,
383                    &mut diagnostics,
384                    directory_plan,
385                    context,
386                    definition.evidence(),
387                    file.value(),
388                    file.span(),
389                    PathPurpose::SecretFile {
390                        secret: definition.name().to_owned(),
391                    },
392                );
393            }
394        }
395    }
396
397    IncludedResourcePathResolution {
398        paths,
399        diagnostics,
400        upstream_complete: composition.is_complete() && directory_plan.is_complete(),
401    }
402}
403
404fn included_bind_source(mount: &VolumeMount) -> Option<(&str, SourceSpan)> {
405    match mount {
406        VolumeMount::Short(mount) => {
407            let source = mount.source()?;
408            is_path_source(source).then_some((source, mount.raw().span()))
409        }
410        VolumeMount::Long(mount) if mount.mount_type().is_some_and(|kind| *kind.value() == MountType::Bind) => {
411            mount.source().map(|source| (source.value().as_str(), source.span()))
412        }
413        VolumeMount::Long(_) => None,
414    }
415}
416
417#[allow(clippy::too_many_arguments)]
418fn push_included_resource_path(
419    paths: &mut Vec<IncludedResourcePath>,
420    diagnostics: &mut Vec<Diagnostic>,
421    directory_plan: &IncludeProjectDirectoryPlan,
422    context: &PathContext,
423    evidence: &IncludeDefinitionEvidence,
424    raw: &str,
425    source: SourceSpan,
426    purpose: PathPurpose,
427) {
428    let occurrence_index = evidence.occurrence_index();
429    let plan_entry = directory_plan.entry(occurrence_index);
430    let aligned_entry =
431        plan_entry.filter(|entry| entry.node_index() == occurrence_index && entry.identity() == evidence.identity());
432    let base_directory = match (plan_entry, aligned_entry) {
433        (Some(_), None) | (None, _) => {
434            diagnostics.push(
435                Diagnostic::new(
436                    INCLUDE_RESOURCE_PATH_PLAN_MISMATCH,
437                    Severity::Error,
438                    "included resource path and directory plan describe different occurrences",
439                )
440                .with_label(DiagnosticLabel::primary(source, "directory plan occurrence mismatch")),
441            );
442            None
443        }
444        (_, Some(entry)) => {
445            if let Some(directory) = entry.effective_directory() {
446                Some(directory.to_path_buf())
447            } else {
448                diagnostics.push(
449                    Diagnostic::new(
450                        INCLUDE_RESOURCE_PATH_BASE_UNAVAILABLE,
451                        Severity::Error,
452                        "included resource path has no authorized project directory",
453                    )
454                    .with_label(DiagnosticLabel::primary(source, "project directory unavailable")),
455                );
456                None
457            }
458        }
459    };
460    let kind = classify(raw);
461    let resolved = base_directory
462        .as_deref()
463        .and_then(|base| resolve_lexically(base, context, raw, kind));
464    if base_directory.is_some() && kind == HostPathKind::HomeRelative && resolved.is_none() {
465        diagnostics.push(
466            Diagnostic::new(
467                HOME_DIRECTORY_REQUIRED,
468                Severity::Warning,
469                "home-relative path requires an explicit home directory",
470            )
471            .with_label(DiagnosticLabel::primary(source, "home directory not supplied")),
472        );
473    }
474    paths.push(IncludedResourcePath {
475        raw: raw.to_owned(),
476        kind,
477        purpose,
478        source,
479        occurrence_index,
480        identity: evidence.identity().clone(),
481        base_directory,
482        resolved,
483    });
484}
485
486/// Finds and lexically resolves paths covered by the initial conversion boundary.
487#[must_use]
488pub fn resolve_paths(
489    project: &MergedProject,
490    selection: Option<&ProfileSelection>,
491    context: &PathContext,
492) -> PathResolution {
493    let mut diagnostics = Vec::new();
494    if !selection_matches(project, selection, &mut diagnostics) {
495        return PathResolution {
496            paths: Vec::new(),
497            diagnostics,
498        };
499    }
500
501    let mut paths = Vec::new();
502    for service in service_entries(project) {
503        if !service_in_scope(selection, service.key()) {
504            continue;
505        }
506        let Some(volumes) = service.value().get("volumes").and_then(MergedValue::as_sequence) else {
507            continue;
508        };
509        for (index, volume) in volumes.iter().enumerate() {
510            let source = bind_source(volume);
511            if let Some((source, span, sensitive)) = source {
512                push_path(
513                    &mut paths,
514                    &mut diagnostics,
515                    project.base_directory(),
516                    context,
517                    &source,
518                    span,
519                    sensitive,
520                    PathPurpose::ServiceBind {
521                        service: service.key().to_owned(),
522                        index,
523                    },
524                );
525            }
526        }
527    }
528
529    collect_resource_files(project, "configs", true, context, &mut paths, &mut diagnostics);
530    collect_resource_files(project, "secrets", false, context, &mut paths, &mut diagnostics);
531    PathResolution { paths, diagnostics }
532}
533
534fn bind_source(volume: &MergedValue) -> Option<(String, SourceSpan, bool)> {
535    if let Some(scalar) = volume.as_scalar() {
536        let span = super::effective_span(volume);
537        let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), span));
538        let source = mount.source()?;
539        return is_path_source(source).then_some((source.to_owned(), span, scalar.is_sensitive()));
540    }
541    if volume
542        .get("type")
543        .and_then(MergedValue::as_scalar)
544        .map(MergedScalar::value)
545        != Some("bind")
546    {
547        return None;
548    }
549    let source = volume.get("source")?;
550    let scalar = source.as_scalar()?;
551    Some((
552        scalar.value().to_owned(),
553        super::effective_span(source),
554        scalar.is_sensitive(),
555    ))
556}
557
558fn collect_resource_files(
559    project: &MergedProject,
560    field: &str,
561    config: bool,
562    context: &PathContext,
563    paths: &mut Vec<ResolvedHostPath>,
564    diagnostics: &mut Vec<Diagnostic>,
565) {
566    let Some(resources) = project.root().get(field).and_then(MergedValue::as_mapping) else {
567        return;
568    };
569    for resource in resources {
570        let Some(file) = resource.value().get("file") else {
571            continue;
572        };
573        let Some(scalar) = file.as_scalar() else {
574            continue;
575        };
576        let purpose = if config {
577            PathPurpose::ConfigFile {
578                config: resource.key().to_owned(),
579            }
580        } else {
581            PathPurpose::SecretFile {
582                secret: resource.key().to_owned(),
583            }
584        };
585        push_path(
586            paths,
587            diagnostics,
588            project.base_directory(),
589            context,
590            scalar.value(),
591            super::effective_span(file),
592            scalar.is_sensitive(),
593            purpose,
594        );
595    }
596}
597
598#[allow(clippy::too_many_arguments)]
599fn push_path(
600    paths: &mut Vec<ResolvedHostPath>,
601    diagnostics: &mut Vec<Diagnostic>,
602    base: &Path,
603    context: &PathContext,
604    raw: &str,
605    source: SourceSpan,
606    sensitive: bool,
607    purpose: PathPurpose,
608) {
609    let kind = classify(raw);
610    let resolved = resolve_lexically(base, context, raw, kind);
611    if kind == HostPathKind::HomeRelative && resolved.is_none() {
612        diagnostics.push(
613            Diagnostic::new(
614                HOME_DIRECTORY_REQUIRED,
615                Severity::Warning,
616                "home-relative path requires an explicit home directory",
617            )
618            .with_label(DiagnosticLabel::primary(source, "home directory not supplied")),
619        );
620    }
621    paths.push(ResolvedHostPath {
622        raw: raw.to_owned(),
623        kind,
624        purpose,
625        source,
626        origin: base.to_path_buf(),
627        resolved,
628        sensitive,
629    });
630}
631
632fn resolve_lexically(base: &Path, context: &PathContext, raw: &str, kind: HostPathKind) -> Option<PathBuf> {
633    match kind {
634        HostPathKind::Relative => Some(base.join(raw)),
635        HostPathKind::UnixAbsolute | HostPathKind::WindowsDriveAbsolute | HostPathKind::WindowsUnc => {
636            Some(PathBuf::from(raw))
637        }
638        HostPathKind::HomeRelative => context.home_directory.as_ref().map(|home| {
639            raw.strip_prefix("~/")
640                .map_or_else(|| home.clone(), |suffix| home.join(suffix))
641        }),
642    }
643}
644
645fn classify(value: &str) -> HostPathKind {
646    if value == "~" || value.starts_with("~/") {
647        HostPathKind::HomeRelative
648    } else if is_windows_drive_absolute(value) {
649        HostPathKind::WindowsDriveAbsolute
650    } else if value.starts_with("\\\\") {
651        HostPathKind::WindowsUnc
652    } else if value.starts_with('/') {
653        HostPathKind::UnixAbsolute
654    } else {
655        HostPathKind::Relative
656    }
657}
658
659pub(crate) fn is_path_source(value: &str) -> bool {
660    value == "."
661        || value == ".."
662        || value.starts_with("./")
663        || value.starts_with("../")
664        || value.starts_with('/')
665        || value == "~"
666        || value.starts_with("~/")
667        || value.starts_with("\\\\")
668        || is_windows_drive_absolute(value)
669}
670
671fn is_windows_drive_absolute(value: &str) -> bool {
672    let bytes = value.as_bytes();
673    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/')
674}