use std::path::{Component, Path};
use crate::model::{Manifest, ResourceKind};
impl Manifest {
pub fn resolve_host_volume_paths(&mut self, base_dir: &Path) {
for kind in self.resources.values_mut() {
let volumes = match kind {
ResourceKind::Container(c) => &mut c.volumes,
ResourceKind::Dockerfile(c) => &mut c.volumes,
ResourceKind::Postgres(_) | ResourceKind::Redis(_) => continue,
};
for mapping in volumes.iter_mut() {
if let Some(resolved) = resolve_mapping(mapping, base_dir) {
*mapping = resolved;
}
}
}
}
}
fn resolve_mapping(mapping: &str, base_dir: &Path) -> Option<String> {
let (src, target) = mapping.split_once(':')?;
if !src.starts_with('.') {
return None;
}
let relative = src.strip_prefix("./").unwrap_or(src);
if Path::new(relative)
.components()
.any(|c| c == Component::ParentDir)
{
return None;
}
let absolute = base_dir.join(relative);
Some(format!("{}:{target}", absolute.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn base() -> PathBuf {
PathBuf::from(if cfg!(windows) {
r"C:\project"
} else {
"/project"
})
}
#[test]
fn relative_source_is_resolved_against_base() {
let expected = format!(
"{}:/etc/demo.conf",
base().join("config/demo.conf").display()
);
assert_eq!(
resolve_mapping("./config/demo.conf:/etc/demo.conf", &base()).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn parent_relative_source_is_rejected() {
assert_eq!(
resolve_mapping("../shared/x:/etc/x", &base()),
None,
"paths escaping the base directory via '..' must be rejected"
);
}
#[test]
fn embedded_traversal_is_rejected() {
assert_eq!(
resolve_mapping("./foo/../../etc/passwd:/etc/passwd", &base()),
None,
"embedded '..' traversal must be rejected"
);
}
#[test]
fn absolute_source_is_unchanged() {
assert_eq!(resolve_mapping("/data/x:/etc/x", &base()), None);
}
#[test]
fn named_source_is_unchanged() {
assert_eq!(resolve_mapping("dbdata:/var/lib/data", &base()), None);
}
#[test]
fn resolve_only_touches_host_mounts() {
let yaml = r"
project:
name: app
resources:
svc:
container:
image: alpine
volumes:
- ./config:/etc/config
- cache:/var/cache
db:
postgres:
version: '16'
volume: dbdata
";
let mut manifest = Manifest::parse(yaml).expect("parses");
manifest.resolve_host_volume_paths(&base());
let ResourceKind::Container(svc) = &manifest.resources["svc"] else {
panic!("svc is a container");
};
let expected = format!("{}:/etc/config", base().join("config").display());
assert_eq!(svc.volumes[0], expected, "relative host mount resolved");
assert_eq!(svc.volumes[1], "cache:/var/cache", "named volume untouched");
}
}