Skip to main content

opencode_cloud_core/docker/
mount.rs

1//! Bind mount parsing and validation for container configuration.
2//!
3//! This module provides functionality to:
4//! - Parse mount strings in Docker format (`/host:/container[:ro|rw]`)
5//! - Validate mount paths (existence, type, permissions)
6//! - Convert parsed mounts to Bollard's Mount type for Docker API
7//! - Warn about potentially dangerous container mount points
8
9use bollard::service::{Mount, MountTypeEnum};
10use std::path::PathBuf;
11use thiserror::Error;
12
13/// Errors that can occur during mount parsing and validation.
14#[derive(Debug, Error)]
15pub enum MountError {
16    /// Mount path is relative, but must be absolute.
17    #[error("Mount paths must be absolute. Use: /full/path/to/dir (got: {0})")]
18    RelativePath(String),
19
20    /// Mount string format is invalid.
21    #[error("Invalid mount format. Expected: /host/path:/container/path[:ro] (got: {0})")]
22    InvalidFormat(String),
23
24    /// Path does not exist or cannot be accessed.
25    #[error("Path not found: {0} ({1})")]
26    PathNotFound(String, String),
27
28    /// Path exists but is not a directory.
29    #[error("Path is not a directory: {0}")]
30    NotADirectory(String),
31
32    /// Permission denied accessing path.
33    #[error("Cannot access path (permission denied): {0}")]
34    PermissionDenied(String),
35}
36
37/// A parsed bind mount specification.
38#[derive(Debug, Clone, PartialEq)]
39pub struct ParsedMount {
40    /// Host path to mount (absolute).
41    pub host_path: PathBuf,
42
43    /// Container path where the host path is mounted.
44    pub container_path: String,
45
46    /// Whether the mount is read-only.
47    pub read_only: bool,
48}
49
50impl ParsedMount {
51    /// Parse a mount string in Docker format.
52    ///
53    /// Format: `/host/path:/container/path[:ro|rw]`
54    ///
55    /// # Arguments
56    /// * `mount_str` - The mount specification string.
57    ///
58    /// # Returns
59    /// * `Ok(ParsedMount)` - Successfully parsed mount.
60    /// * `Err(MountError)` - Parse error.
61    ///
62    /// # Examples
63    /// ```
64    /// use opencode_cloud_core::docker::ParsedMount;
65    ///
66    /// // Read-write mount (default)
67    /// let mount = ParsedMount::parse("/home/user/data:/workspace/data").unwrap();
68    /// assert_eq!(mount.host_path.to_str().unwrap(), "/home/user/data");
69    /// assert_eq!(mount.container_path, "/workspace/data");
70    /// assert!(!mount.read_only);
71    ///
72    /// // Read-only mount
73    /// let mount = ParsedMount::parse("/home/user/config:/etc/app:ro").unwrap();
74    /// assert!(mount.read_only);
75    /// ```
76    pub fn parse(mount_str: &str) -> Result<Self, MountError> {
77        let parts: Vec<&str> = mount_str.split(':').collect();
78
79        match parts.len() {
80            2 => {
81                // /host:/container (default rw)
82                let host_path = PathBuf::from(parts[0]);
83                if !host_path.is_absolute() {
84                    return Err(MountError::RelativePath(parts[0].to_string()));
85                }
86                Ok(Self {
87                    host_path,
88                    container_path: parts[1].to_string(),
89                    read_only: false,
90                })
91            }
92            3 => {
93                // /host:/container:ro or /host:/container:rw
94                let host_path = PathBuf::from(parts[0]);
95                if !host_path.is_absolute() {
96                    return Err(MountError::RelativePath(parts[0].to_string()));
97                }
98                let read_only = match parts[2].to_lowercase().as_str() {
99                    "ro" => true,
100                    "rw" => false,
101                    _ => return Err(MountError::InvalidFormat(mount_str.to_string())),
102                };
103                Ok(Self {
104                    host_path,
105                    container_path: parts[1].to_string(),
106                    read_only,
107                })
108            }
109            _ => Err(MountError::InvalidFormat(mount_str.to_string())),
110        }
111    }
112
113    /// Convert to a Bollard Mount for the Docker API.
114    ///
115    /// Returns a bind mount with the parsed host and container paths.
116    pub fn to_bollard_mount(&self) -> Mount {
117        Mount {
118            target: Some(self.container_path.clone()),
119            source: Some(self.host_path.to_string_lossy().to_string()),
120            typ: Some(MountTypeEnum::BIND),
121            read_only: Some(self.read_only),
122            ..Default::default()
123        }
124    }
125}
126
127/// Validate that a mount host path exists and is accessible.
128///
129/// Checks:
130/// 1. Path is absolute.
131/// 2. Path exists (via canonicalize, which also resolves symlinks).
132/// 3. Path is a directory.
133///
134/// # Arguments
135/// * `path` - The path to validate.
136///
137/// # Returns
138/// * `Ok(PathBuf)` - The canonical (resolved) path.
139/// * `Err(MountError)` - Validation error.
140pub fn validate_mount_path(path: &std::path::Path) -> Result<PathBuf, MountError> {
141    // Check absolute
142    if !path.is_absolute() {
143        return Err(MountError::RelativePath(path.display().to_string()));
144    }
145
146    // Canonicalize (resolves symlinks, checks existence)
147    let canonical = std::fs::canonicalize(path).map_err(|e| {
148        if e.kind() == std::io::ErrorKind::PermissionDenied {
149            MountError::PermissionDenied(path.display().to_string())
150        } else {
151            MountError::PathNotFound(path.display().to_string(), e.to_string())
152        }
153    })?;
154
155    // Check it's a directory
156    let metadata = std::fs::metadata(&canonical).map_err(|e| {
157        if e.kind() == std::io::ErrorKind::PermissionDenied {
158            MountError::PermissionDenied(path.display().to_string())
159        } else {
160            MountError::PathNotFound(path.display().to_string(), e.to_string())
161        }
162    })?;
163
164    if !metadata.is_dir() {
165        return Err(MountError::NotADirectory(path.display().to_string()));
166    }
167
168    Ok(canonical)
169}
170
171/// System paths that should typically not be mounted over.
172const SYSTEM_PATHS: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/lib", "/var"];
173
174/// Check if mounting to a container path might be dangerous.
175///
176/// Returns a warning message if the container path is a system path,
177/// or `None` if the path appears safe.
178///
179/// # Arguments
180/// * `container_path` - The path inside the container.
181///
182/// # Returns
183/// * `Some(String)` - Warning message about the system path.
184/// * `None` - Path appears safe.
185pub fn check_container_path_warning(container_path: &str) -> Option<String> {
186    for system_path in SYSTEM_PATHS {
187        if container_path == *system_path || container_path.starts_with(&format!("{system_path}/"))
188        {
189            return Some(format!(
190                "Warning: mounting to '{container_path}' may affect container system files"
191            ));
192        }
193    }
194    None
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn parse_valid_mount_rw() {
203        let mount = ParsedMount::parse("/a:/b").unwrap();
204        assert_eq!(mount.host_path, PathBuf::from("/a"));
205        assert_eq!(mount.container_path, "/b");
206        assert!(!mount.read_only);
207    }
208
209    #[test]
210    fn parse_valid_mount_ro() {
211        let mount = ParsedMount::parse("/a:/b:ro").unwrap();
212        assert_eq!(mount.host_path, PathBuf::from("/a"));
213        assert_eq!(mount.container_path, "/b");
214        assert!(mount.read_only);
215    }
216
217    #[test]
218    fn parse_valid_mount_explicit_rw() {
219        let mount = ParsedMount::parse("/a:/b:rw").unwrap();
220        assert_eq!(mount.host_path, PathBuf::from("/a"));
221        assert_eq!(mount.container_path, "/b");
222        assert!(!mount.read_only);
223    }
224
225    #[test]
226    fn parse_valid_mount_ro_uppercase() {
227        let mount = ParsedMount::parse("/a:/b:RO").unwrap();
228        assert!(mount.read_only);
229    }
230
231    #[test]
232    fn parse_invalid_format_single_part() {
233        let result = ParsedMount::parse("invalid");
234        assert!(matches!(result, Err(MountError::InvalidFormat(_))));
235    }
236
237    #[test]
238    fn parse_invalid_format_too_many_parts() {
239        let result = ParsedMount::parse("/a:/b:ro:extra");
240        assert!(matches!(result, Err(MountError::InvalidFormat(_))));
241    }
242
243    #[test]
244    fn parse_invalid_format_bad_mode() {
245        let result = ParsedMount::parse("/a:/b:invalid");
246        assert!(matches!(result, Err(MountError::InvalidFormat(_))));
247    }
248
249    #[test]
250    fn parse_relative_path_rejected() {
251        let result = ParsedMount::parse("./rel:/b");
252        assert!(matches!(result, Err(MountError::RelativePath(_))));
253    }
254
255    #[test]
256    fn parse_relative_path_no_dot_rejected() {
257        let result = ParsedMount::parse("relative/path:/b");
258        assert!(matches!(result, Err(MountError::RelativePath(_))));
259    }
260
261    #[test]
262    fn system_path_warning_etc() {
263        let warning = check_container_path_warning("/etc");
264        assert!(warning.is_some());
265        assert!(warning.unwrap().contains("/etc"));
266    }
267
268    #[test]
269    fn system_path_warning_etc_subdir() {
270        let warning = check_container_path_warning("/etc/passwd");
271        assert!(warning.is_some());
272    }
273
274    #[test]
275    fn system_path_warning_usr() {
276        let warning = check_container_path_warning("/usr");
277        assert!(warning.is_some());
278    }
279
280    #[test]
281    fn system_path_warning_usr_local() {
282        let warning = check_container_path_warning("/usr/local");
283        assert!(warning.is_some());
284    }
285
286    #[test]
287    fn non_system_path_no_warning() {
288        let warning = check_container_path_warning("/workspace/data");
289        assert!(warning.is_none());
290    }
291
292    #[test]
293    fn non_system_path_home_no_warning() {
294        let warning = check_container_path_warning("/home/user/data");
295        assert!(warning.is_none());
296    }
297
298    #[test]
299    fn to_bollard_mount_structure() {
300        let mount = ParsedMount {
301            host_path: PathBuf::from("/host/path"),
302            container_path: "/container/path".to_string(),
303            read_only: true,
304        };
305        let bollard_mount = mount.to_bollard_mount();
306        assert_eq!(bollard_mount.target, Some("/container/path".to_string()));
307        assert_eq!(bollard_mount.source, Some("/host/path".to_string()));
308        assert_eq!(bollard_mount.typ, Some(MountTypeEnum::BIND));
309        assert_eq!(bollard_mount.read_only, Some(true));
310    }
311
312    #[test]
313    fn validate_mount_path_relative_rejected() {
314        let result = validate_mount_path(std::path::Path::new("./relative"));
315        assert!(matches!(result, Err(MountError::RelativePath(_))));
316    }
317
318    #[test]
319    fn validate_mount_path_nonexistent() {
320        let result = validate_mount_path(std::path::Path::new("/nonexistent/path/xyz123"));
321        assert!(matches!(result, Err(MountError::PathNotFound(_, _))));
322    }
323
324    #[test]
325    fn validate_mount_path_existing_directory() {
326        // Use /tmp which should exist on any Unix system
327        let result = validate_mount_path(std::path::Path::new("/tmp"));
328        assert!(result.is_ok());
329    }
330}