@opencode-cloud/core 1.0.10 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  //! Docker containers for the opencode-cloud service.
5
5
 
6
6
  use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7
+ use super::mount::ParsedMount;
7
8
  use super::volume::{
8
9
  MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9
10
  };
@@ -12,7 +13,9 @@ use bollard::container::{
12
13
  Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
13
14
  StopContainerOptions,
14
15
  };
15
- use bollard::service::{HostConfig, Mount, MountTypeEnum, PortBinding, PortMap};
16
+ use bollard::service::{
17
+ HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
18
+ };
16
19
  use std::collections::HashMap;
17
20
  use tracing::debug;
18
21
 
@@ -36,6 +39,7 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
36
39
  /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
37
40
  /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
38
41
  /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
42
+ /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
39
43
  #[allow(clippy::too_many_arguments)]
40
44
  pub async fn create_container(
41
45
  client: &DockerClient,
@@ -46,6 +50,7 @@ pub async fn create_container(
46
50
  bind_address: Option<&str>,
47
51
  cockpit_port: Option<u16>,
48
52
  cockpit_enabled: Option<bool>,
53
+ bind_mounts: Option<Vec<ParsedMount>>,
49
54
  ) -> Result<String, DockerError> {
50
55
  let container_name = name.unwrap_or(CONTAINER_NAME);
51
56
  let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
@@ -81,7 +86,7 @@ pub async fn create_container(
81
86
  }
82
87
 
83
88
  // Create volume mounts
84
- let mounts = vec![
89
+ let mut mounts = vec![
85
90
  Mount {
86
91
  target: Some(MOUNT_SESSION.to_string()),
87
92
  source: Some(VOLUME_SESSION.to_string()),
@@ -105,6 +110,13 @@ pub async fn create_container(
105
110
  },
106
111
  ];
107
112
 
113
+ // Add user-defined bind mounts from config/CLI
114
+ if let Some(ref user_mounts) = bind_mounts {
115
+ for parsed in user_mounts {
116
+ mounts.push(parsed.to_bollard_mount());
117
+ }
118
+ }
119
+
108
120
  // Create port bindings (default to localhost for security)
109
121
  let bind_addr = bind_address.unwrap_or("127.0.0.1");
110
122
  let mut port_bindings: PortMap = HashMap::new();
@@ -356,6 +368,105 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
356
368
  }
357
369
  }
358
370
 
371
+ /// Container port configuration
372
+ #[derive(Debug, Clone)]
373
+ pub struct ContainerPorts {
374
+ /// Host port for opencode web UI (mapped from container port 3000)
375
+ pub opencode_port: Option<u16>,
376
+ /// Host port for Cockpit (mapped from container port 9090)
377
+ pub cockpit_port: Option<u16>,
378
+ }
379
+
380
+ /// A bind mount from an existing container
381
+ #[derive(Debug, Clone)]
382
+ pub struct ContainerBindMount {
383
+ /// Source path on host
384
+ pub source: String,
385
+ /// Target path in container
386
+ pub target: String,
387
+ /// Read-only flag
388
+ pub read_only: bool,
389
+ }
390
+
391
+ /// Get the port bindings from an existing container
392
+ ///
393
+ /// Returns the host ports that the container's internal ports are mapped to.
394
+ /// Returns None for ports that aren't mapped.
395
+ pub async fn get_container_ports(
396
+ client: &DockerClient,
397
+ name: &str,
398
+ ) -> Result<ContainerPorts, DockerError> {
399
+ debug!("Getting container ports: {}", name);
400
+
401
+ let info = client
402
+ .inner()
403
+ .inspect_container(name, None)
404
+ .await
405
+ .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
406
+
407
+ let port_bindings = info
408
+ .host_config
409
+ .and_then(|hc| hc.port_bindings)
410
+ .unwrap_or_default();
411
+
412
+ // Extract opencode port (3000/tcp -> host port)
413
+ let opencode_port = port_bindings
414
+ .get("3000/tcp")
415
+ .and_then(|bindings| bindings.as_ref())
416
+ .and_then(|bindings| bindings.first())
417
+ .and_then(|binding| binding.host_port.as_ref())
418
+ .and_then(|port_str| port_str.parse::<u16>().ok());
419
+
420
+ // Extract cockpit port (9090/tcp -> host port)
421
+ let cockpit_port = port_bindings
422
+ .get("9090/tcp")
423
+ .and_then(|bindings| bindings.as_ref())
424
+ .and_then(|bindings| bindings.first())
425
+ .and_then(|binding| binding.host_port.as_ref())
426
+ .and_then(|port_str| port_str.parse::<u16>().ok());
427
+
428
+ Ok(ContainerPorts {
429
+ opencode_port,
430
+ cockpit_port,
431
+ })
432
+ }
433
+
434
+ /// Get bind mounts from an existing container
435
+ ///
436
+ /// Returns only user-defined bind mounts (excludes system mounts like cgroup).
437
+ pub async fn get_container_bind_mounts(
438
+ client: &DockerClient,
439
+ name: &str,
440
+ ) -> Result<Vec<ContainerBindMount>, DockerError> {
441
+ debug!("Getting container bind mounts: {}", name);
442
+
443
+ let info = client
444
+ .inner()
445
+ .inspect_container(name, None)
446
+ .await
447
+ .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
448
+
449
+ let mounts = info.mounts.unwrap_or_default();
450
+
451
+ // Filter to only bind mounts, excluding system paths
452
+ let bind_mounts: Vec<ContainerBindMount> = mounts
453
+ .iter()
454
+ .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
455
+ .filter(|m| {
456
+ // Exclude system mounts (cgroup, etc.)
457
+ let target = m.destination.as_deref().unwrap_or("");
458
+ !target.starts_with("/sys/")
459
+ })
460
+ .map(|m| ContainerBindMount {
461
+ source: m.source.clone().unwrap_or_default(),
462
+ target: m.destination.clone().unwrap_or_default(),
463
+ read_only: m.rw.map(|rw| !rw).unwrap_or(false),
464
+ })
465
+ .collect();
466
+
467
+ Ok(bind_mounts)
468
+ }
469
+
359
470
  #[cfg(test)]
360
471
  mod tests {
361
472
  use super::*;
@@ -369,6 +480,6 @@ mod tests {
369
480
  #[test]
370
481
  fn default_image_format() {
371
482
  let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
372
- assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
483
+ assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
373
484
  }
374
485
  }
@@ -1,9 +1,13 @@
1
1
  //! Embedded Dockerfile content
2
2
  //!
3
- //! This module contains the Dockerfile for building the opencode-cloud container image,
4
- //! embedded at compile time for distribution with the CLI.
3
+ //! This module contains the Dockerfile for building the opencode-cloud-sandbox
4
+ //! container image, embedded at compile time for distribution with the CLI.
5
+ //!
6
+ //! Note: The image is named "opencode-cloud-sandbox" (not "opencode-cloud") to
7
+ //! clearly indicate this is the sandboxed container environment that the
8
+ //! opencode-cloud CLI deploys, not the CLI tool itself.
5
9
 
6
- /// The Dockerfile for building the opencode-cloud container image
10
+ /// The Dockerfile for building the opencode-cloud-sandbox container image
7
11
  pub const DOCKERFILE: &str = include_str!("Dockerfile");
8
12
 
9
13
  // =============================================================================
@@ -15,27 +19,26 @@ pub const DOCKERFILE: &str = include_str!("Dockerfile");
15
19
  // - Registry: The server hosting the image (e.g., ghcr.io, gcr.io, docker.io)
16
20
  // When omitted, Docker Hub (docker.io) is assumed.
17
21
  // - Namespace: Usually the username or organization (e.g., prizz)
18
- // - Image: The image name (e.g., opencode-cloud)
22
+ // - Image: The image name (e.g., opencode-cloud-sandbox)
19
23
  // - Tag: Version identifier (e.g., latest, v1.0.0). Defaults to "latest" if omitted.
20
24
  //
21
25
  // Examples:
22
- // ghcr.io/prizz/opencode-cloud:latest - GitHub Container Registry
23
- // prizz/opencode-cloud:latest - Docker Hub (registry omitted)
24
- // gcr.io/my-project/myapp:v1.0 - Google Container Registry
26
+ // ghcr.io/prizz/opencode-cloud-sandbox:latest - GitHub Container Registry
27
+ // prizz/opencode-cloud-sandbox:latest - Docker Hub (registry omitted)
28
+ // gcr.io/my-project/myapp:v1.0 - Google Container Registry
25
29
  //
26
- // We use GHCR as the primary registry since it integrates well with GitHub
27
- // Actions for CI/CD publishing.
30
+ // We publish to both GHCR (primary) and Docker Hub for maximum accessibility.
28
31
  // =============================================================================
29
32
 
30
33
  /// Docker image name for GitHub Container Registry (primary registry)
31
34
  ///
32
35
  /// Format: `ghcr.io/{github-username}/{image-name}`
33
- pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud";
36
+ pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud-sandbox";
34
37
 
35
- /// Docker image name for Docker Hub (fallback registry)
38
+ /// Docker image name for Docker Hub (secondary registry)
36
39
  ///
37
40
  /// Format: `{dockerhub-username}/{image-name}` (registry prefix omitted for Docker Hub)
38
- pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud";
41
+ pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud-sandbox";
39
42
 
40
43
  /// Default image tag
41
44
  pub const IMAGE_TAG_DEFAULT: &str = "latest";
package/src/docker/mod.rs CHANGED
@@ -19,7 +19,9 @@ mod error;
19
19
  pub mod exec;
20
20
  mod health;
21
21
  pub mod image;
22
+ pub mod mount;
22
23
  pub mod progress;
24
+ pub mod state;
23
25
  pub mod update;
24
26
  pub mod users;
25
27
  mod version;
@@ -62,12 +64,19 @@ pub use volume::{
62
64
  VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
63
65
  };
64
66
 
67
+ // Bind mount parsing and validation
68
+ pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};
69
+
65
70
  // Container lifecycle
66
71
  pub use container::{
67
- CONTAINER_NAME, OPENCODE_WEB_PORT, container_exists, container_is_running, container_state,
68
- create_container, remove_container, start_container, stop_container,
72
+ CONTAINER_NAME, ContainerBindMount, ContainerPorts, OPENCODE_WEB_PORT, container_exists,
73
+ container_is_running, container_state, create_container, get_container_bind_mounts,
74
+ get_container_ports, remove_container, start_container, stop_container,
69
75
  };
70
76
 
77
+ // Image state tracking
78
+ pub use state::{ImageState, clear_state, get_state_path, load_state, save_state};
79
+
71
80
  /// Full setup: ensure volumes exist, create container if needed, start it
72
81
  ///
73
82
  /// This is the primary entry point for starting the opencode service.
@@ -80,6 +89,7 @@ pub use container::{
80
89
  /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
81
90
  /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
82
91
  /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
92
+ /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
83
93
  pub async fn setup_and_start(
84
94
  client: &DockerClient,
85
95
  opencode_web_port: Option<u16>,
@@ -87,6 +97,7 @@ pub async fn setup_and_start(
87
97
  bind_address: Option<&str>,
88
98
  cockpit_port: Option<u16>,
89
99
  cockpit_enabled: Option<bool>,
100
+ bind_mounts: Option<Vec<mount::ParsedMount>>,
90
101
  ) -> Result<String, DockerError> {
91
102
  // Ensure volumes exist first
92
103
  volume::ensure_volumes_exist(client).await?;
@@ -114,6 +125,7 @@ pub async fn setup_and_start(
114
125
  bind_address,
115
126
  cockpit_port,
116
127
  cockpit_enabled,
128
+ bind_mounts,
117
129
  )
118
130
  .await?
119
131
  };
@@ -126,13 +138,22 @@ pub async fn setup_and_start(
126
138
  Ok(container_id)
127
139
  }
128
140
 
141
+ /// Default graceful shutdown timeout in seconds
142
+ pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
143
+
129
144
  /// Stop and optionally remove the opencode container
130
145
  ///
131
146
  /// # Arguments
132
147
  /// * `client` - Docker client
133
148
  /// * `remove` - Also remove the container after stopping
134
- pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), DockerError> {
149
+ /// * `timeout_secs` - Graceful shutdown timeout (default: 30 seconds)
150
+ pub async fn stop_service(
151
+ client: &DockerClient,
152
+ remove: bool,
153
+ timeout_secs: Option<i64>,
154
+ ) -> Result<(), DockerError> {
135
155
  let name = container::CONTAINER_NAME;
156
+ let timeout = timeout_secs.unwrap_or(DEFAULT_STOP_TIMEOUT_SECS);
136
157
 
137
158
  // Check if container exists
138
159
  if !container::container_exists(client, name).await? {
@@ -143,7 +164,7 @@ pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), Doc
143
164
 
144
165
  // Stop if running
145
166
  if container::container_is_running(client, name).await? {
146
- container::stop_container(client, name, None).await?;
167
+ container::stop_container(client, name, Some(timeout)).await?;
147
168
  }
148
169
 
149
170
  // Remove if requested
@@ -0,0 +1,330 @@
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
+
9
+ use bollard::service::{Mount, MountTypeEnum};
10
+ use std::path::PathBuf;
11
+ use thiserror::Error;
12
+
13
+ /// Errors that can occur during mount parsing and validation.
14
+ #[derive(Debug, Error)]
15
+ pub 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)]
39
+ pub 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
+
50
+ impl 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.
140
+ pub 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.
172
+ const 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.
185
+ pub 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)]
198
+ mod 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
+ }