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
13pub const HOME_DIRECTORY_REQUIRED: DiagnosticCode = DiagnosticCode::new("compose.paths.home-directory-required");
15pub const INCLUDE_RESOURCE_PATH_BASE_UNAVAILABLE: DiagnosticCode =
17 DiagnosticCode::new("compose.include.resource-path-base-unavailable");
18pub const INCLUDE_RESOURCE_PATH_PLAN_MISMATCH: DiagnosticCode =
20 DiagnosticCode::new("compose.include.resource-path-plan-mismatch");
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum HostPathKind {
25 Relative,
27 UnixAbsolute,
29 WindowsDriveAbsolute,
31 WindowsUnc,
33 HomeRelative,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub enum PathPurpose {
40 ServiceBind {
42 service: String,
44 index: usize,
46 },
47 ConfigFile {
49 config: String,
51 },
52 SecretFile {
54 secret: String,
56 },
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct PathContext {
62 home_directory: Option<PathBuf>,
63}
64
65impl PathContext {
66 #[must_use]
68 pub const fn new() -> Self {
69 Self { home_directory: None }
70 }
71
72 #[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 #[must_use]
81 pub fn home_directory(&self) -> Option<&Path> {
82 self.home_directory.as_deref()
83 }
84}
85
86#[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 #[must_use]
101 pub fn raw(&self) -> &str {
102 &self.raw
103 }
104
105 #[must_use]
107 pub const fn kind(&self) -> HostPathKind {
108 self.kind
109 }
110
111 #[must_use]
113 pub const fn purpose(&self) -> &PathPurpose {
114 &self.purpose
115 }
116
117 #[must_use]
119 pub const fn source(&self) -> SourceSpan {
120 self.source
121 }
122
123 #[must_use]
125 pub fn origin(&self) -> &Path {
126 &self.origin
127 }
128
129 #[must_use]
134 pub fn resolved(&self) -> Option<&Path> {
135 self.resolved.as_deref()
136 }
137
138 #[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#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct PathResolution {
165 paths: Vec<ResolvedHostPath>,
166 diagnostics: Vec<Diagnostic>,
167}
168
169impl PathResolution {
170 #[must_use]
172 pub fn paths(&self) -> &[ResolvedHostPath] {
173 &self.paths
174 }
175
176 #[must_use]
178 pub fn diagnostics(&self) -> &[Diagnostic] {
179 &self.diagnostics
180 }
181
182 #[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#[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 #[must_use]
208 pub fn raw(&self) -> &str {
209 &self.raw
210 }
211
212 #[must_use]
214 pub const fn kind(&self) -> HostPathKind {
215 self.kind
216 }
217
218 #[must_use]
220 pub const fn purpose(&self) -> &PathPurpose {
221 &self.purpose
222 }
223
224 #[must_use]
232 pub const fn source(&self) -> SourceSpan {
233 self.source
234 }
235
236 #[must_use]
238 pub const fn occurrence_index(&self) -> usize {
239 self.occurrence_index
240 }
241
242 #[must_use]
244 pub const fn identity(&self) -> &IncludeIdentity {
245 &self.identity
246 }
247
248 #[must_use]
250 pub fn base_directory(&self) -> Option<&Path> {
251 self.base_directory.as_deref()
252 }
253
254 #[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#[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 #[must_use]
292 pub fn paths(&self) -> &[IncludedResourcePath] {
293 &self.paths
294 }
295
296 #[must_use]
298 pub fn diagnostics(&self) -> &[Diagnostic] {
299 &self.diagnostics
300 }
301
302 #[must_use]
304 pub fn is_valid(&self) -> bool {
305 self.diagnostics
306 .iter()
307 .all(|diagnostic| diagnostic.severity() != Severity::Error)
308 }
309
310 #[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#[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#[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}