opencode_cloud_core/docker/
mount.rs1use bollard::service::{Mount, MountTypeEnum};
10use std::path::PathBuf;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum MountError {
16 #[error("Mount paths must be absolute. Use: /full/path/to/dir (got: {0})")]
18 RelativePath(String),
19
20 #[error("Invalid mount format. Expected: /host/path:/container/path[:ro] (got: {0})")]
22 InvalidFormat(String),
23
24 #[error("Path not found: {0} ({1})")]
26 PathNotFound(String, String),
27
28 #[error("Path is not a directory: {0}")]
30 NotADirectory(String),
31
32 #[error("Cannot access path (permission denied): {0}")]
34 PermissionDenied(String),
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct ParsedMount {
40 pub host_path: PathBuf,
42
43 pub container_path: String,
45
46 pub read_only: bool,
48}
49
50impl ParsedMount {
51 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 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 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 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
127pub fn validate_mount_path(path: &std::path::Path) -> Result<PathBuf, MountError> {
141 if !path.is_absolute() {
143 return Err(MountError::RelativePath(path.display().to_string()));
144 }
145
146 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 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
171const SYSTEM_PATHS: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/lib", "/var"];
173
174pub 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 let result = validate_mount_path(std::path::Path::new("/tmp"));
328 assert!(result.is_ok());
329 }
330}