use super::{selection_matches, service_entries, service_in_scope};
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
use crate::loader::{
IncludeCompositionResult, IncludeDefinitionEvidence, IncludeIdentity, IncludeProjectDirectoryPlan,
};
use crate::merge::{MergedProject, MergedScalar, MergedValue};
use crate::model::{Located, MountType, ShortVolumeMount, VolumeMount};
use crate::profiles::ProfileSelection;
use crate::source::SourceSpan;
use std::fmt;
use std::path::{Path, PathBuf};
pub const HOME_DIRECTORY_REQUIRED: DiagnosticCode = DiagnosticCode::new("compose.paths.home-directory-required");
pub const INCLUDE_RESOURCE_PATH_BASE_UNAVAILABLE: DiagnosticCode =
DiagnosticCode::new("compose.include.resource-path-base-unavailable");
pub const INCLUDE_RESOURCE_PATH_PLAN_MISMATCH: DiagnosticCode =
DiagnosticCode::new("compose.include.resource-path-plan-mismatch");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HostPathKind {
Relative,
UnixAbsolute,
WindowsDriveAbsolute,
WindowsUnc,
HomeRelative,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathPurpose {
ServiceBind {
service: String,
index: usize,
},
ConfigFile {
config: String,
},
SecretFile {
secret: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PathContext {
home_directory: Option<PathBuf>,
}
impl PathContext {
#[must_use]
pub const fn new() -> Self {
Self { home_directory: None }
}
#[must_use]
pub fn with_home_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.home_directory = Some(directory.into());
self
}
#[must_use]
pub fn home_directory(&self) -> Option<&Path> {
self.home_directory.as_deref()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ResolvedHostPath {
raw: String,
kind: HostPathKind,
purpose: PathPurpose,
source: SourceSpan,
origin: PathBuf,
resolved: Option<PathBuf>,
sensitive: bool,
}
impl ResolvedHostPath {
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn kind(&self) -> HostPathKind {
self.kind
}
#[must_use]
pub const fn purpose(&self) -> &PathPurpose {
&self.purpose
}
#[must_use]
pub const fn source(&self) -> SourceSpan {
self.source
}
#[must_use]
pub fn origin(&self) -> &Path {
&self.origin
}
#[must_use]
pub fn resolved(&self) -> Option<&Path> {
self.resolved.as_deref()
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for ResolvedHostPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let raw = if self.sensitive { "<redacted>" } else { &self.raw };
let resolved = if self.sensitive { None } else { self.resolved.as_deref() };
formatter
.debug_struct("ResolvedHostPath")
.field("raw", &raw)
.field("kind", &self.kind)
.field("purpose", &self.purpose)
.field("source", &self.source)
.field("origin", &self.origin)
.field("resolved", &resolved)
.field("sensitive", &self.sensitive)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathResolution {
paths: Vec<ResolvedHostPath>,
diagnostics: Vec<Diagnostic>,
}
impl PathResolution {
#[must_use]
pub fn paths(&self) -> &[ResolvedHostPath] {
&self.paths
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct IncludedResourcePath {
raw: String,
kind: HostPathKind,
purpose: PathPurpose,
source: SourceSpan,
occurrence_index: usize,
identity: IncludeIdentity,
base_directory: Option<PathBuf>,
resolved: Option<PathBuf>,
}
impl IncludedResourcePath {
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn kind(&self) -> HostPathKind {
self.kind
}
#[must_use]
pub const fn purpose(&self) -> &PathPurpose {
&self.purpose
}
#[must_use]
pub const fn source(&self) -> SourceSpan {
self.source
}
#[must_use]
pub const fn occurrence_index(&self) -> usize {
self.occurrence_index
}
#[must_use]
pub const fn identity(&self) -> &IncludeIdentity {
&self.identity
}
#[must_use]
pub fn base_directory(&self) -> Option<&Path> {
self.base_directory.as_deref()
}
#[must_use]
pub fn resolved(&self) -> Option<&Path> {
self.resolved.as_deref()
}
}
impl fmt::Debug for IncludedResourcePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("IncludedResourcePath")
.field("raw", &"<redacted-path>")
.field("kind", &self.kind)
.field("purpose", &self.purpose)
.field("source", &self.source)
.field("occurrence_index", &self.occurrence_index)
.field("identity", &"<redacted-identity>")
.field(
"base_directory",
&self.base_directory.as_ref().map(|_| "<authorized-directory>"),
)
.field("resolved", &self.resolved.as_ref().map(|_| "<resolved-path>"))
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludedResourcePathResolution {
paths: Vec<IncludedResourcePath>,
diagnostics: Vec<Diagnostic>,
upstream_complete: bool,
}
impl IncludedResourcePathResolution {
#[must_use]
pub fn paths(&self) -> &[IncludedResourcePath] {
&self.paths
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.upstream_complete && self.is_valid() && self.paths.iter().all(|path| path.resolved.is_some())
}
}
#[must_use]
pub fn resolve_included_resource_paths(
composition: &IncludeCompositionResult,
directory_plan: &IncludeProjectDirectoryPlan,
context: &PathContext,
) -> IncludedResourcePathResolution {
let common_prefix = composition
.diagnostics()
.iter()
.zip(directory_plan.diagnostics())
.take_while(|(left, right)| left == right)
.count();
let mut diagnostics = composition.diagnostics().to_vec();
diagnostics.extend_from_slice(&directory_plan.diagnostics()[common_prefix..]);
let mut paths = Vec::new();
if let Some(root) = composition.root() {
for definition in root.services() {
let Some(volumes) = definition.definition().volumes() else {
continue;
};
for (index, mount) in volumes.value().iter().enumerate() {
let Some((raw, source)) = included_bind_source(mount.value()) else {
continue;
};
push_included_resource_path(
&mut paths,
&mut diagnostics,
directory_plan,
context,
definition.evidence(),
raw,
source,
PathPurpose::ServiceBind {
service: definition.name().to_owned(),
index,
},
);
}
}
for definition in root.configs() {
if let Some(file) = definition.definition().file() {
push_included_resource_path(
&mut paths,
&mut diagnostics,
directory_plan,
context,
definition.evidence(),
file.value(),
file.span(),
PathPurpose::ConfigFile {
config: definition.name().to_owned(),
},
);
}
}
for definition in root.secrets() {
if let Some(file) = definition.definition().file() {
push_included_resource_path(
&mut paths,
&mut diagnostics,
directory_plan,
context,
definition.evidence(),
file.value(),
file.span(),
PathPurpose::SecretFile {
secret: definition.name().to_owned(),
},
);
}
}
}
IncludedResourcePathResolution {
paths,
diagnostics,
upstream_complete: composition.is_complete() && directory_plan.is_complete(),
}
}
fn included_bind_source(mount: &VolumeMount) -> Option<(&str, SourceSpan)> {
match mount {
VolumeMount::Short(mount) => {
let source = mount.source()?;
is_path_source(source).then_some((source, mount.raw().span()))
}
VolumeMount::Long(mount) if mount.mount_type().is_some_and(|kind| *kind.value() == MountType::Bind) => {
mount.source().map(|source| (source.value().as_str(), source.span()))
}
VolumeMount::Long(_) => None,
}
}
#[allow(clippy::too_many_arguments)]
fn push_included_resource_path(
paths: &mut Vec<IncludedResourcePath>,
diagnostics: &mut Vec<Diagnostic>,
directory_plan: &IncludeProjectDirectoryPlan,
context: &PathContext,
evidence: &IncludeDefinitionEvidence,
raw: &str,
source: SourceSpan,
purpose: PathPurpose,
) {
let occurrence_index = evidence.occurrence_index();
let plan_entry = directory_plan.entry(occurrence_index);
let aligned_entry =
plan_entry.filter(|entry| entry.node_index() == occurrence_index && entry.identity() == evidence.identity());
let base_directory = match (plan_entry, aligned_entry) {
(Some(_), None) | (None, _) => {
diagnostics.push(
Diagnostic::new(
INCLUDE_RESOURCE_PATH_PLAN_MISMATCH,
Severity::Error,
"included resource path and directory plan describe different occurrences",
)
.with_label(DiagnosticLabel::primary(source, "directory plan occurrence mismatch")),
);
None
}
(_, Some(entry)) => {
if let Some(directory) = entry.effective_directory() {
Some(directory.to_path_buf())
} else {
diagnostics.push(
Diagnostic::new(
INCLUDE_RESOURCE_PATH_BASE_UNAVAILABLE,
Severity::Error,
"included resource path has no authorized project directory",
)
.with_label(DiagnosticLabel::primary(source, "project directory unavailable")),
);
None
}
}
};
let kind = classify(raw);
let resolved = base_directory
.as_deref()
.and_then(|base| resolve_lexically(base, context, raw, kind));
if base_directory.is_some() && kind == HostPathKind::HomeRelative && resolved.is_none() {
diagnostics.push(
Diagnostic::new(
HOME_DIRECTORY_REQUIRED,
Severity::Warning,
"home-relative path requires an explicit home directory",
)
.with_label(DiagnosticLabel::primary(source, "home directory not supplied")),
);
}
paths.push(IncludedResourcePath {
raw: raw.to_owned(),
kind,
purpose,
source,
occurrence_index,
identity: evidence.identity().clone(),
base_directory,
resolved,
});
}
#[must_use]
pub fn resolve_paths(
project: &MergedProject,
selection: Option<&ProfileSelection>,
context: &PathContext,
) -> PathResolution {
let mut diagnostics = Vec::new();
if !selection_matches(project, selection, &mut diagnostics) {
return PathResolution {
paths: Vec::new(),
diagnostics,
};
}
let mut paths = Vec::new();
for service in service_entries(project) {
if !service_in_scope(selection, service.key()) {
continue;
}
let Some(volumes) = service.value().get("volumes").and_then(MergedValue::as_sequence) else {
continue;
};
for (index, volume) in volumes.iter().enumerate() {
let source = bind_source(volume);
if let Some((source, span, sensitive)) = source {
push_path(
&mut paths,
&mut diagnostics,
project.base_directory(),
context,
&source,
span,
sensitive,
PathPurpose::ServiceBind {
service: service.key().to_owned(),
index,
},
);
}
}
}
collect_resource_files(project, "configs", true, context, &mut paths, &mut diagnostics);
collect_resource_files(project, "secrets", false, context, &mut paths, &mut diagnostics);
PathResolution { paths, diagnostics }
}
fn bind_source(volume: &MergedValue) -> Option<(String, SourceSpan, bool)> {
if let Some(scalar) = volume.as_scalar() {
let span = super::effective_span(volume);
let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), span));
let source = mount.source()?;
return is_path_source(source).then_some((source.to_owned(), span, scalar.is_sensitive()));
}
if volume
.get("type")
.and_then(MergedValue::as_scalar)
.map(MergedScalar::value)
!= Some("bind")
{
return None;
}
let source = volume.get("source")?;
let scalar = source.as_scalar()?;
Some((
scalar.value().to_owned(),
super::effective_span(source),
scalar.is_sensitive(),
))
}
fn collect_resource_files(
project: &MergedProject,
field: &str,
config: bool,
context: &PathContext,
paths: &mut Vec<ResolvedHostPath>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(resources) = project.root().get(field).and_then(MergedValue::as_mapping) else {
return;
};
for resource in resources {
let Some(file) = resource.value().get("file") else {
continue;
};
let Some(scalar) = file.as_scalar() else {
continue;
};
let purpose = if config {
PathPurpose::ConfigFile {
config: resource.key().to_owned(),
}
} else {
PathPurpose::SecretFile {
secret: resource.key().to_owned(),
}
};
push_path(
paths,
diagnostics,
project.base_directory(),
context,
scalar.value(),
super::effective_span(file),
scalar.is_sensitive(),
purpose,
);
}
}
#[allow(clippy::too_many_arguments)]
fn push_path(
paths: &mut Vec<ResolvedHostPath>,
diagnostics: &mut Vec<Diagnostic>,
base: &Path,
context: &PathContext,
raw: &str,
source: SourceSpan,
sensitive: bool,
purpose: PathPurpose,
) {
let kind = classify(raw);
let resolved = resolve_lexically(base, context, raw, kind);
if kind == HostPathKind::HomeRelative && resolved.is_none() {
diagnostics.push(
Diagnostic::new(
HOME_DIRECTORY_REQUIRED,
Severity::Warning,
"home-relative path requires an explicit home directory",
)
.with_label(DiagnosticLabel::primary(source, "home directory not supplied")),
);
}
paths.push(ResolvedHostPath {
raw: raw.to_owned(),
kind,
purpose,
source,
origin: base.to_path_buf(),
resolved,
sensitive,
});
}
fn resolve_lexically(base: &Path, context: &PathContext, raw: &str, kind: HostPathKind) -> Option<PathBuf> {
match kind {
HostPathKind::Relative => Some(base.join(raw)),
HostPathKind::UnixAbsolute | HostPathKind::WindowsDriveAbsolute | HostPathKind::WindowsUnc => {
Some(PathBuf::from(raw))
}
HostPathKind::HomeRelative => context.home_directory.as_ref().map(|home| {
raw.strip_prefix("~/")
.map_or_else(|| home.clone(), |suffix| home.join(suffix))
}),
}
}
fn classify(value: &str) -> HostPathKind {
if value == "~" || value.starts_with("~/") {
HostPathKind::HomeRelative
} else if is_windows_drive_absolute(value) {
HostPathKind::WindowsDriveAbsolute
} else if value.starts_with("\\\\") {
HostPathKind::WindowsUnc
} else if value.starts_with('/') {
HostPathKind::UnixAbsolute
} else {
HostPathKind::Relative
}
}
pub(crate) fn is_path_source(value: &str) -> bool {
value == "."
|| value == ".."
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with('/')
|| value == "~"
|| value.starts_with("~/")
|| value.starts_with("\\\\")
|| is_windows_drive_absolute(value)
}
fn is_windows_drive_absolute(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/')
}