use bollard::service::{Mount, MountTypeEnum};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MountError {
#[error("Mount paths must be absolute. Use: /full/path/to/dir (got: {0})")]
RelativePath(String),
#[error("Invalid mount format. Expected: /host/path:/container/path[:ro] (got: {0})")]
InvalidFormat(String),
#[error("Path not found: {0} ({1})")]
PathNotFound(String, String),
#[error("Path is not a directory: {0}")]
NotADirectory(String),
#[error("Cannot access path (permission denied): {0}")]
PermissionDenied(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedMount {
pub host_path: PathBuf,
pub container_path: String,
pub read_only: bool,
}
impl ParsedMount {
pub fn parse(mount_str: &str) -> Result<Self, MountError> {
let parts: Vec<&str> = mount_str.split(':').collect();
match parts.len() {
2 => {
let host_path = PathBuf::from(parts[0]);
if !host_path.is_absolute() {
return Err(MountError::RelativePath(parts[0].to_string()));
}
Ok(Self {
host_path,
container_path: parts[1].to_string(),
read_only: false,
})
}
3 => {
let host_path = PathBuf::from(parts[0]);
if !host_path.is_absolute() {
return Err(MountError::RelativePath(parts[0].to_string()));
}
let read_only = match parts[2].to_lowercase().as_str() {
"ro" => true,
"rw" => false,
_ => return Err(MountError::InvalidFormat(mount_str.to_string())),
};
Ok(Self {
host_path,
container_path: parts[1].to_string(),
read_only,
})
}
_ => Err(MountError::InvalidFormat(mount_str.to_string())),
}
}
pub fn to_bollard_mount(&self) -> Mount {
Mount {
target: Some(self.container_path.clone()),
source: Some(self.host_path.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(self.read_only),
..Default::default()
}
}
}
pub fn validate_mount_path(path: &std::path::Path) -> Result<PathBuf, MountError> {
if !path.is_absolute() {
return Err(MountError::RelativePath(path.display().to_string()));
}
let canonical = std::fs::canonicalize(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
MountError::PermissionDenied(path.display().to_string())
} else {
MountError::PathNotFound(path.display().to_string(), e.to_string())
}
})?;
let metadata = std::fs::metadata(&canonical).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
MountError::PermissionDenied(path.display().to_string())
} else {
MountError::PathNotFound(path.display().to_string(), e.to_string())
}
})?;
if !metadata.is_dir() {
return Err(MountError::NotADirectory(path.display().to_string()));
}
Ok(canonical)
}
const SYSTEM_PATHS: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/lib", "/var"];
pub fn check_container_path_warning(container_path: &str) -> Option<String> {
for system_path in SYSTEM_PATHS {
if container_path == *system_path || container_path.starts_with(&format!("{system_path}/"))
{
return Some(format!(
"Warning: mounting to '{container_path}' may affect container system files"
));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_mount_rw() {
let mount = ParsedMount::parse("/a:/b").unwrap();
assert_eq!(mount.host_path, PathBuf::from("/a"));
assert_eq!(mount.container_path, "/b");
assert!(!mount.read_only);
}
#[test]
fn parse_valid_mount_ro() {
let mount = ParsedMount::parse("/a:/b:ro").unwrap();
assert_eq!(mount.host_path, PathBuf::from("/a"));
assert_eq!(mount.container_path, "/b");
assert!(mount.read_only);
}
#[test]
fn parse_valid_mount_explicit_rw() {
let mount = ParsedMount::parse("/a:/b:rw").unwrap();
assert_eq!(mount.host_path, PathBuf::from("/a"));
assert_eq!(mount.container_path, "/b");
assert!(!mount.read_only);
}
#[test]
fn parse_valid_mount_ro_uppercase() {
let mount = ParsedMount::parse("/a:/b:RO").unwrap();
assert!(mount.read_only);
}
#[test]
fn parse_invalid_format_single_part() {
let result = ParsedMount::parse("invalid");
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
}
#[test]
fn parse_invalid_format_too_many_parts() {
let result = ParsedMount::parse("/a:/b:ro:extra");
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
}
#[test]
fn parse_invalid_format_bad_mode() {
let result = ParsedMount::parse("/a:/b:invalid");
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
}
#[test]
fn parse_relative_path_rejected() {
let result = ParsedMount::parse("./rel:/b");
assert!(matches!(result, Err(MountError::RelativePath(_))));
}
#[test]
fn parse_relative_path_no_dot_rejected() {
let result = ParsedMount::parse("relative/path:/b");
assert!(matches!(result, Err(MountError::RelativePath(_))));
}
#[test]
fn system_path_warning_etc() {
let warning = check_container_path_warning("/etc");
assert!(warning.is_some());
assert!(warning.unwrap().contains("/etc"));
}
#[test]
fn system_path_warning_etc_subdir() {
let warning = check_container_path_warning("/etc/passwd");
assert!(warning.is_some());
}
#[test]
fn system_path_warning_usr() {
let warning = check_container_path_warning("/usr");
assert!(warning.is_some());
}
#[test]
fn system_path_warning_usr_local() {
let warning = check_container_path_warning("/usr/local");
assert!(warning.is_some());
}
#[test]
fn non_system_path_no_warning() {
let warning = check_container_path_warning("/home/opencoder/workspace/data");
assert!(warning.is_none());
}
#[test]
fn non_system_path_home_no_warning() {
let warning = check_container_path_warning("/home/user/data");
assert!(warning.is_none());
}
#[test]
fn to_bollard_mount_structure() {
let mount = ParsedMount {
host_path: PathBuf::from("/host/path"),
container_path: "/container/path".to_string(),
read_only: true,
};
let bollard_mount = mount.to_bollard_mount();
assert_eq!(bollard_mount.target, Some("/container/path".to_string()));
assert_eq!(bollard_mount.source, Some("/host/path".to_string()));
assert_eq!(bollard_mount.typ, Some(MountTypeEnum::BIND));
assert_eq!(bollard_mount.read_only, Some(true));
}
#[test]
fn validate_mount_path_relative_rejected() {
let result = validate_mount_path(std::path::Path::new("./relative"));
assert!(matches!(result, Err(MountError::RelativePath(_))));
}
#[test]
fn validate_mount_path_nonexistent() {
let result = validate_mount_path(std::path::Path::new("/nonexistent/path/xyz123"));
assert!(matches!(result, Err(MountError::PathNotFound(_, _))));
}
#[test]
fn validate_mount_path_existing_directory() {
let result = validate_mount_path(std::path::Path::new("/tmp"));
assert!(result.is_ok());
}
}