clnrm_core/backend/
testcontainer.rs

1//! Testcontainers backend for containerized command execution
2//!
3//! Provides testcontainers-rs integration for hermetic, isolated execution
4//! with automatic container lifecycle management.
5
6use crate::backend::volume::{VolumeMount, VolumeValidator};
7use crate::backend::{Backend, Cmd, RunResult};
8use crate::error::{BackendError, Result};
9use crate::policy::Policy;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12use testcontainers::{core::ExecCommand, runners::SyncRunner, GenericImage, ImageExt};
13
14#[cfg(feature = "otel-traces")]
15use tracing::{info, instrument, warn};
16
17/// Testcontainers backend for containerized execution
18#[derive(Debug, Clone)]
19pub struct TestcontainerBackend {
20    /// Base image configuration
21    image_name: String,
22    image_tag: String,
23    /// Default policy
24    policy: Policy,
25    /// Command execution timeout
26    timeout: Duration,
27    /// Container startup timeout
28    startup_timeout: Duration,
29    /// Environment variables to set in container
30    env_vars: std::collections::HashMap<String, String>,
31    /// Default command to run in container
32    default_command: Option<Vec<String>>,
33    /// Volume mounts for the container
34    volume_mounts: Vec<VolumeMount>,
35    /// Volume validator for security checks
36    volume_validator: Arc<VolumeValidator>,
37    /// Memory limit in MB
38    memory_limit: Option<u64>,
39    /// CPU limit (number of CPUs)
40    cpu_limit: Option<f64>,
41}
42
43impl TestcontainerBackend {
44    /// Create a new testcontainers backend
45    pub fn new(image: impl Into<String>) -> Result<Self> {
46        let image_str = image.into();
47
48        // Parse image name and tag
49        let (image_name, image_tag) = if let Some((name, tag)) = image_str.split_once(':') {
50            (name.to_string(), tag.to_string())
51        } else {
52            (image_str, "latest".to_string())
53        };
54
55        Ok(Self {
56            image_name,
57            image_tag,
58            policy: Policy::default(),
59            timeout: Duration::from_secs(30), // Reduced from 300s
60            startup_timeout: Duration::from_secs(10), // Reduced from 60s
61            env_vars: std::collections::HashMap::new(),
62            default_command: None,
63            volume_mounts: Vec::new(),
64            volume_validator: Arc::new(VolumeValidator::default()),
65            memory_limit: None,
66            cpu_limit: None,
67        })
68    }
69
70    /// Create with custom policy
71    pub fn with_policy(mut self, policy: Policy) -> Self {
72        self.policy = policy;
73        self
74    }
75
76    /// Create with custom execution timeout
77    pub fn with_timeout(mut self, timeout: Duration) -> Self {
78        self.timeout = timeout;
79        self
80    }
81
82    /// Create with custom startup timeout
83    pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
84        self.startup_timeout = timeout;
85        self
86    }
87
88    /// Check if the backend is running
89    pub fn is_running(&self) -> bool {
90        // For testcontainers, we consider the backend "running" if it can be created
91        // In a real implementation, this might check container status
92        true
93    }
94
95    /// Add environment variable to container
96    pub fn with_env(mut self, key: &str, val: &str) -> Self {
97        self.env_vars.insert(key.to_string(), val.to_string());
98        self
99    }
100
101    /// Set default command for container
102    pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
103        self.default_command = Some(cmd);
104        self
105    }
106
107    /// Add volume mount
108    ///
109    /// # Arguments
110    ///
111    /// * `host_path` - Path on the host system
112    /// * `container_path` - Path inside the container
113    /// * `read_only` - Whether mount is read-only
114    ///
115    /// # Errors
116    ///
117    /// Returns error if volume validation fails
118    pub fn with_volume(
119        mut self,
120        host_path: &str,
121        container_path: &str,
122        read_only: bool,
123    ) -> Result<Self> {
124        let mount = VolumeMount::new(host_path, container_path, read_only)?;
125        self.volume_validator.validate(&mount)?;
126        self.volume_mounts.push(mount);
127        Ok(self)
128    }
129
130    /// Add read-only volume mount
131    ///
132    /// Convenience method for adding read-only mounts
133    pub fn with_volume_ro(self, host_path: &str, container_path: &str) -> Result<Self> {
134        self.with_volume(host_path, container_path, true)
135    }
136
137    /// Set volume validator with custom whitelist
138    pub fn with_volume_validator(mut self, validator: VolumeValidator) -> Self {
139        self.volume_validator = Arc::new(validator);
140        self
141    }
142
143    /// Get volume mounts
144    pub fn volumes(&self) -> &[VolumeMount] {
145        &self.volume_mounts
146    }
147
148    /// Set memory limit in MB
149    pub fn with_memory_limit(mut self, limit_mb: u64) -> Self {
150        self.memory_limit = Some(limit_mb);
151        self
152    }
153
154    /// Set CPU limit (number of CPUs)
155    pub fn with_cpu_limit(mut self, cpus: f64) -> Self {
156        self.cpu_limit = Some(cpus);
157        self
158    }
159
160    /// Check if testcontainers is available
161    pub fn is_available() -> bool {
162        // For now, assume Docker is available if we can create a GenericImage
163        true
164    }
165
166    /// Validate OpenTelemetry instrumentation (if enabled)
167    ///
168    /// This method validates that OTel spans are created correctly during
169    /// container operations. Following core team standards:
170    /// - No .unwrap() or .expect()
171    /// - Sync method (dyn compatible)
172    /// - Returns Result<T, CleanroomError>
173    #[cfg(feature = "otel-traces")]
174    pub fn validate_otel_instrumentation(&self) -> Result<bool> {
175        // Check if OTel is initialized
176        use crate::telemetry::validation::is_otel_initialized;
177
178        if !is_otel_initialized() {
179            return Err(crate::error::CleanroomError::validation_error(
180                "OpenTelemetry is not initialized. Enable OTEL features and call init_otel()",
181            ));
182        }
183
184        // Basic validation - more comprehensive validation requires
185        // integration with in-memory span exporter
186        Ok(true)
187    }
188
189    /// Get OpenTelemetry validation status
190    #[cfg(feature = "otel-traces")]
191    pub fn otel_validation_enabled(&self) -> bool {
192        true
193    }
194
195    #[cfg(not(feature = "otel-traces"))]
196    pub fn otel_validation_enabled(&self) -> bool {
197        false
198    }
199
200    /// Execute command in container
201    #[cfg_attr(feature = "otel-traces", instrument(name = "clnrm.container.exec", skip(self, cmd), fields(container.image = %self.image_name, container.tag = %self.image_tag, component = "container_backend")))]
202    fn execute_in_container(&self, cmd: &Cmd) -> Result<RunResult> {
203        let start_time = Instant::now();
204
205        #[cfg(feature = "otel-traces")]
206        info!(
207            "Starting container with image {}:{}",
208            self.image_name, self.image_tag
209        );
210
211        // Create a unique container ID for tracing
212        #[allow(unused_variables)]
213        let container_id = uuid::Uuid::new_v4().to_string();
214
215        #[cfg(feature = "otel-traces")]
216        {
217            use opentelemetry::global;
218            use opentelemetry::trace::{Span, Tracer, TracerProvider};
219            use crate::telemetry::events;
220
221            // Get current span and record container.start event
222            let tracer_provider = global::tracer_provider();
223            let mut span = tracer_provider
224                .tracer("clnrm-backend")
225                .start("clnrm.container.start");
226
227            events::record_container_start(
228                &mut span,
229                &format!("{}:{}", self.image_name, self.image_tag),
230                &container_id,
231            );
232            span.end();
233        }
234
235        // Docker availability will be checked by the container startup itself
236
237        // Create base image
238        let image = GenericImage::new(self.image_name.clone(), self.image_tag.clone());
239
240        // Build container request with all configurations
241        let mut container_request: testcontainers::core::ContainerRequest<
242            testcontainers::GenericImage,
243        > = image.into();
244
245        // Add environment variables from backend storage
246        for (key, value) in &self.env_vars {
247            container_request = container_request.with_env_var(key, value);
248        }
249
250        // Add environment variables from command
251        for (key, value) in &cmd.env {
252            container_request = container_request.with_env_var(key, value);
253        }
254
255        // Add policy environment variables
256        for (key, value) in self.policy.to_env() {
257            container_request = container_request.with_env_var(key, value);
258        }
259
260        // Add volume mounts from backend storage
261        for mount in &self.volume_mounts {
262            use testcontainers::core::{AccessMode, Mount};
263
264            let access_mode = if mount.is_read_only() {
265                AccessMode::ReadOnly
266            } else {
267                AccessMode::ReadWrite
268            };
269
270            let bind_mount = Mount::bind_mount(
271                mount.host_path().to_string_lossy().to_string(),
272                mount.container_path().to_string_lossy().to_string(),
273            )
274            .with_access_mode(access_mode);
275
276            container_request = container_request.with_mount(bind_mount);
277        }
278
279        // Set a default command to keep the container running
280        // Alpine containers exit immediately without a command
281        container_request = container_request.with_cmd(vec!["sleep", "3600"]);
282
283        // Set working directory if specified
284        if let Some(workdir) = &cmd.workdir {
285            container_request =
286                container_request.with_working_dir(workdir.to_string_lossy().to_string());
287        }
288
289        // Start container using SyncRunner with timeout monitoring
290        let container_start_time = Instant::now();
291        let container = container_request
292            .start()
293            .map_err(|e| {
294                let elapsed = container_start_time.elapsed();
295                if elapsed > Duration::from_secs(10) {
296                    #[cfg(feature = "otel-traces")]
297                    warn!("Container startup took {}s, which is longer than expected. First pull of image may take time.", elapsed.as_secs());
298                }
299
300                BackendError::Runtime(format!(
301                    "Failed to start container with image '{}:{}' after {}s.\n\
302                    Possible causes:\n\
303                      - Docker daemon not running (try: docker ps)\n\
304                      - Image needs to be pulled (first run may take longer)\n\
305                      - Network issues preventing image pull\n\
306                    Try: Increase startup timeout or check Docker status\n\
307                    Original error: {}", 
308                    self.image_name, self.image_tag, elapsed.as_secs(), e
309                ))
310            })?;
311
312        #[cfg(feature = "otel-traces")]
313        info!("Container started successfully, executing command");
314
315        // Execute command - testcontainers expects Vec<&str> for exec
316        let cmd_args: Vec<&str> = std::iter::once(cmd.bin.as_str())
317            .chain(cmd.args.iter().map(|s| s.as_str()))
318            .collect();
319
320        #[allow(unused_variables)]
321        let cmd_string = format!("{} {}", cmd.bin, cmd.args.join(" "));
322
323        let exec_cmd = ExecCommand::new(cmd_args);
324        let mut exec_result = container
325            .exec(exec_cmd)
326            .map_err(|e| BackendError::Runtime(format!("Command execution failed: {}", e)))?;
327
328        let duration_ms = start_time.elapsed().as_millis() as u64;
329
330        #[cfg(feature = "otel-traces")]
331        info!("Command completed in {}ms", duration_ms);
332
333        // Extract output - SyncExecResult provides stdout() and stderr() as streams
334        use std::io::Read;
335        let mut stdout = String::new();
336        let mut stderr = String::new();
337
338        exec_result
339            .stdout()
340            .read_to_string(&mut stdout)
341            .map_err(|e| BackendError::Runtime(format!("Failed to read stdout: {}", e)))?;
342        exec_result
343            .stderr()
344            .read_to_string(&mut stderr)
345            .map_err(|e| BackendError::Runtime(format!("Failed to read stderr: {}", e)))?;
346
347        // Extract exit code with proper error handling
348        // testcontainers may return None if exit code is unavailable
349        let exit_code = exec_result
350            .exit_code()
351            .map_err(|e| BackendError::Runtime(format!("Failed to get exit code: {}", e)))?
352            .unwrap_or(-1) as i32;
353
354        #[cfg(feature = "otel-traces")]
355        {
356            use opentelemetry::global;
357            use opentelemetry::trace::{Span, Tracer, TracerProvider};
358            use crate::telemetry::events;
359
360            // Record container.exec event
361            let tracer_provider = global::tracer_provider();
362            let mut exec_span = tracer_provider
363                .tracer("clnrm-backend")
364                .start("clnrm.container.exec");
365
366            events::record_container_exec(&mut exec_span, &cmd_string, exit_code);
367            exec_span.end();
368
369            // Record container.stop event
370            let mut stop_span = tracer_provider
371                .tracer("clnrm-backend")
372                .start("clnrm.container.stop");
373
374            events::record_container_stop(&mut stop_span, &container_id, exit_code);
375            stop_span.end();
376        }
377
378        Ok(RunResult {
379            exit_code,
380            stdout,
381            stderr,
382            duration_ms,
383            steps: Vec::new(),
384            redacted_env: Vec::new(),
385            backend: "testcontainers".to_string(),
386            concurrent: false,
387            step_order: Vec::new(),
388        })
389    }
390}
391
392impl Backend for TestcontainerBackend {
393    fn run_cmd(&self, cmd: Cmd) -> Result<RunResult> {
394        // Use synchronous execution with timeout
395        let start_time = Instant::now();
396
397        // Execute command with timeout
398        let result = self.execute_in_container(&cmd)?;
399
400        // Check if execution exceeded timeout
401        if start_time.elapsed() > self.timeout {
402            return Err(crate::error::CleanroomError::timeout_error(format!(
403                "Command execution timed out after {} seconds",
404                self.timeout.as_secs()
405            )));
406        }
407
408        Ok(result)
409    }
410
411    fn name(&self) -> &str {
412        "testcontainers"
413    }
414
415    fn is_available(&self) -> bool {
416        Self::is_available()
417    }
418
419    fn supports_hermetic(&self) -> bool {
420        true
421    }
422
423    fn supports_deterministic(&self) -> bool {
424        true
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_testcontainer_backend_creation() {
434        let backend = TestcontainerBackend::new("alpine:latest");
435        assert!(backend.is_ok());
436    }
437
438    #[test]
439    fn test_testcontainer_backend_with_timeout() -> Result<()> {
440        let timeout = Duration::from_secs(60);
441        let backend = TestcontainerBackend::new("alpine:latest")?.with_timeout(timeout);
442        assert!(backend.is_running());
443        Ok(())
444    }
445
446    #[test]
447    fn test_testcontainer_backend_trait() -> Result<()> {
448        let backend = TestcontainerBackend::new("alpine:latest")?;
449        assert!(backend.is_running());
450        Ok(())
451    }
452
453    #[test]
454    fn test_testcontainer_backend_image() -> Result<()> {
455        let backend = TestcontainerBackend::new("ubuntu:20.04")?;
456        assert!(backend.is_running());
457        Ok(())
458    }
459}
460
461#[cfg(test)]
462mod volume_tests {
463    use super::*;
464    use crate::error::CleanroomError;
465
466    // ========================================================================
467    // Volume Mount Builder Tests
468    // ========================================================================
469
470    #[test]
471    fn test_with_volume_adds_mount_to_backend() -> Result<()> {
472        // Arrange
473        let temp_dir = std::env::temp_dir();
474        let host_path = temp_dir.join("test_mount");
475        std::fs::create_dir_all(&host_path)?;
476
477        let backend = TestcontainerBackend::new("alpine:latest")?;
478
479        // Act
480        let backend_with_volume = backend.with_volume(
481            host_path.to_str().ok_or_else(|| {
482                CleanroomError::internal_error("Invalid host path - contains non-UTF8 characters")
483            })?,
484            "/container/path",
485            false,
486        )?;
487
488        // Assert
489        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
490        assert_eq!(
491            backend_with_volume.volume_mounts[0]
492                .container_path()
493                .to_str()
494                .unwrap_or("invalid"),
495            "/container/path"
496        );
497
498        std::fs::remove_dir(&host_path)?;
499        Ok(())
500    }
501
502    #[test]
503    fn test_with_volume_supports_multiple_volumes() -> Result<()> {
504        // Arrange
505        let temp_dir = std::env::temp_dir();
506        let data_path = temp_dir.join("data");
507        let config_path = temp_dir.join("config");
508        let output_path = temp_dir.join("output");
509
510        std::fs::create_dir_all(&data_path)?;
511        std::fs::create_dir_all(&config_path)?;
512        std::fs::create_dir_all(&output_path)?;
513
514        let backend = TestcontainerBackend::new("alpine:latest")?;
515
516        // Act
517        let backend_with_volumes = backend
518            .with_volume(
519                data_path.to_str().ok_or_else(|| {
520                    crate::error::CleanroomError::internal_error("Invalid data path")
521                })?,
522                "/data",
523                false,
524            )?
525            .with_volume(
526                config_path.to_str().ok_or_else(|| {
527                    crate::error::CleanroomError::internal_error("Invalid config path")
528                })?,
529                "/config",
530                false,
531            )?
532            .with_volume(
533                output_path.to_str().ok_or_else(|| {
534                    crate::error::CleanroomError::internal_error("Invalid output path")
535                })?,
536                "/output",
537                false,
538            )?;
539
540        // Assert
541        assert_eq!(backend_with_volumes.volume_mounts.len(), 3);
542
543        std::fs::remove_dir(&data_path)?;
544        std::fs::remove_dir(&config_path)?;
545        std::fs::remove_dir(&output_path)?;
546        Ok(())
547    }
548
549    #[test]
550    fn test_with_volume_preserves_other_settings() -> Result<()> {
551        // Arrange
552        let temp_dir = std::env::temp_dir();
553        let test_path = temp_dir.join("test");
554        std::fs::create_dir_all(&test_path)?;
555
556        let timeout = Duration::from_secs(120);
557        let backend = TestcontainerBackend::new("alpine:latest")?
558            .with_timeout(timeout)
559            .with_env("TEST_VAR", "test_value");
560
561        // Act
562        let backend_with_volume = backend.with_volume(
563            test_path
564                .to_str()
565                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid test path"))?,
566            "/test",
567            false,
568        )?;
569
570        // Assert
571        assert_eq!(backend_with_volume.timeout, timeout);
572        assert_eq!(
573            backend_with_volume.env_vars.get("TEST_VAR"),
574            Some(&"test_value".to_string())
575        );
576        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
577
578        std::fs::remove_dir(&test_path)?;
579        Ok(())
580    }
581
582    // ========================================================================
583    // Path Validation Tests
584    // ========================================================================
585
586    #[test]
587    fn test_with_volume_accepts_absolute_host_paths() -> Result<()> {
588        // Arrange
589        let temp_dir = std::env::temp_dir();
590        let abs_path = temp_dir.join("absolute");
591        std::fs::create_dir_all(&abs_path)?;
592
593        let backend = TestcontainerBackend::new("alpine:latest")?;
594
595        // Act
596        let backend_with_volume = backend.with_volume(
597            abs_path.to_str().ok_or_else(|| {
598                crate::error::CleanroomError::internal_error("Invalid absolute path")
599            })?,
600            "/container/path",
601            false,
602        )?;
603
604        // Assert
605        assert!(backend_with_volume.volume_mounts[0]
606            .host_path()
607            .is_absolute());
608
609        std::fs::remove_dir(&abs_path)?;
610        Ok(())
611    }
612
613    #[test]
614    fn test_with_volume_rejects_relative_container_paths() -> Result<()> {
615        // Arrange
616        let temp_dir = std::env::temp_dir();
617        let backend = TestcontainerBackend::new("alpine:latest")?;
618
619        // Act - Container paths must be absolute now
620        let result = backend.with_volume(
621            temp_dir.to_str().ok_or_else(|| {
622                crate::error::CleanroomError::internal_error("Invalid temp dir path")
623            })?,
624            "relative/container/path",
625            false,
626        );
627
628        // Assert - Should fail validation
629        assert!(result.is_err());
630        Ok(())
631    }
632
633    #[test]
634    fn test_with_volume_handles_special_characters_in_paths() -> Result<()> {
635        // Arrange
636        let temp_dir = std::env::temp_dir();
637        let special_path = temp_dir.join("test-data_v1.0");
638        std::fs::create_dir_all(&special_path)?;
639
640        let backend = TestcontainerBackend::new("alpine:latest")?;
641
642        // Act - Paths with dashes, underscores
643        let backend_with_volume = backend.with_volume(
644            special_path.to_str().ok_or_else(|| {
645                crate::error::CleanroomError::internal_error("Invalid special path")
646            })?,
647            "/container/test-data/v1.0",
648            false,
649        )?;
650
651        // Assert
652        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
653
654        std::fs::remove_dir(&special_path)?;
655        Ok(())
656    }
657
658    #[test]
659    fn test_with_volume_rejects_empty_strings() -> Result<()> {
660        // Arrange
661        let backend = TestcontainerBackend::new("alpine:latest")?;
662
663        // Act - Empty paths should fail validation
664        let result = backend.with_volume("", "", false);
665
666        // Assert
667        assert!(result.is_err());
668        Ok(())
669    }
670
671    // ========================================================================
672    // Builder Pattern Tests
673    // ========================================================================
674
675    #[test]
676    fn test_volume_builder_chain_with_other_methods() -> Result<()> {
677        // Arrange & Act
678        let temp_dir = std::env::temp_dir();
679        let data_path = temp_dir.join("data_chain");
680        std::fs::create_dir_all(&data_path)?;
681
682        let backend = TestcontainerBackend::new("alpine:latest")?
683            .with_policy(Policy::default())
684            .with_timeout(Duration::from_secs(60))
685            .with_env("ENV_VAR", "value")
686            .with_volume(
687                data_path.to_str().ok_or_else(|| {
688                    CleanroomError::internal_error("Invalid path for volume mount")
689                })?,
690                "/data",
691                false,
692            )?
693            .with_memory_limit(512)
694            .with_cpu_limit(1.0);
695
696        // Assert
697        assert_eq!(backend.volume_mounts.len(), 1);
698        assert_eq!(backend.timeout, Duration::from_secs(60));
699        assert_eq!(backend.memory_limit, Some(512));
700        assert_eq!(backend.cpu_limit, Some(1.0));
701
702        std::fs::remove_dir(&data_path)?;
703        Ok(())
704    }
705
706    #[test]
707    fn test_volume_builder_immutability() -> Result<()> {
708        // Arrange
709        let temp_dir = std::env::temp_dir();
710        let test1_path = temp_dir.join("test1");
711        let test2_path = temp_dir.join("test2");
712        std::fs::create_dir_all(&test1_path)?;
713        std::fs::create_dir_all(&test2_path)?;
714
715        let backend1 = TestcontainerBackend::new("alpine:latest")?;
716
717        // Act
718        let backend2 = backend1.clone().with_volume(
719            test1_path.to_str().ok_or_else(|| {
720                crate::error::CleanroomError::internal_error("Invalid test1 path")
721            })?,
722            "/test1",
723            false,
724        )?;
725        let backend3 = backend1.clone().with_volume(
726            test2_path.to_str().ok_or_else(|| {
727                crate::error::CleanroomError::internal_error("Invalid test2 path")
728            })?,
729            "/test2",
730            false,
731        )?;
732
733        // Assert - Each chain creates independent backend
734        assert_eq!(backend2.volume_mounts.len(), 1);
735        assert_eq!(backend3.volume_mounts.len(), 1);
736        assert_ne!(
737            backend2.volume_mounts[0].container_path(),
738            backend3.volume_mounts[0].container_path()
739        );
740
741        std::fs::remove_dir(&test1_path)?;
742        std::fs::remove_dir(&test2_path)?;
743        Ok(())
744    }
745
746    // ========================================================================
747    // Edge Cases
748    // ========================================================================
749
750    #[test]
751    fn test_with_volume_duplicate_mounts_allowed() -> Result<()> {
752        // Arrange
753        let temp_dir = std::env::temp_dir();
754        let data_path = temp_dir.join("data_dup");
755        std::fs::create_dir_all(&data_path)?;
756
757        let backend = TestcontainerBackend::new("alpine:latest")?;
758
759        // Act - Same mount added twice
760        let backend_with_volumes = backend
761            .with_volume(
762                data_path.to_str().ok_or_else(|| {
763                    crate::error::CleanroomError::internal_error("Invalid data path")
764                })?,
765                "/data",
766                false,
767            )?
768            .with_volume(
769                data_path.to_str().ok_or_else(|| {
770                    crate::error::CleanroomError::internal_error("Invalid data path")
771                })?,
772                "/data",
773                false,
774            )?;
775
776        // Assert - Both mounts are added (Docker will handle duplicates)
777        assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
778
779        std::fs::remove_dir(&data_path)?;
780        Ok(())
781    }
782
783    #[test]
784    fn test_with_volume_overlapping_container_paths() -> Result<()> {
785        // Arrange
786        let temp_dir = std::env::temp_dir();
787        let data1_path = temp_dir.join("data1");
788        let data2_path = temp_dir.join("data2");
789        std::fs::create_dir_all(&data1_path)?;
790        std::fs::create_dir_all(&data2_path)?;
791
792        let backend = TestcontainerBackend::new("alpine:latest")?;
793
794        // Act - Different host paths to same container path
795        let backend_with_volumes = backend
796            .with_volume(
797                data1_path.to_str().ok_or_else(|| {
798                    crate::error::CleanroomError::internal_error("Invalid data1 path")
799                })?,
800                "/shared",
801                false,
802            )?
803            .with_volume(
804                data2_path.to_str().ok_or_else(|| {
805                    crate::error::CleanroomError::internal_error("Invalid data2 path")
806                })?,
807                "/shared",
808                false,
809            )?;
810
811        // Assert - Both mounts are added (last one wins in Docker)
812        assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
813
814        std::fs::remove_dir(&data1_path)?;
815        std::fs::remove_dir(&data2_path)?;
816        Ok(())
817    }
818
819    #[test]
820    fn test_with_volume_very_long_paths() -> Result<()> {
821        // Arrange - Create a directory with reasonable length
822        let temp_dir = std::env::temp_dir();
823        let long_name = "a".repeat(100); // Reasonable length
824        let long_path = temp_dir.join(long_name);
825        std::fs::create_dir_all(&long_path)?;
826
827        let backend = TestcontainerBackend::new("alpine:latest")?;
828
829        // Act
830        let backend_with_volume = backend.with_volume(
831            long_path
832                .to_str()
833                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid long path"))?,
834            "/data",
835            false,
836        )?;
837
838        // Assert
839        assert!(
840            backend_with_volume.volume_mounts[0]
841                .host_path()
842                .to_str()
843                .unwrap_or("")
844                .len()
845                > 100
846        );
847
848        std::fs::remove_dir(&long_path)?;
849        Ok(())
850    }
851
852    #[test]
853    #[cfg(target_os = "linux")]
854    fn test_with_volume_unicode_paths() -> Result<()> {
855        // Arrange - Unicode filenames (Linux/macOS support)
856        let temp_dir = std::env::temp_dir();
857        let unicode_path = temp_dir.join("données");
858        std::fs::create_dir_all(&unicode_path)?;
859
860        let backend = TestcontainerBackend::new("alpine:latest")?;
861
862        // Act - Unicode characters in paths
863        let backend_with_volume = backend.with_volume(
864            unicode_path.to_str().ok_or_else(|| {
865                crate::error::CleanroomError::internal_error("Invalid unicode path")
866            })?,
867            "/container/データ",
868            false,
869        )?;
870
871        // Assert
872        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
873
874        std::fs::remove_dir(&unicode_path)?;
875        Ok(())
876    }
877
878    // ========================================================================
879    // Hermetic Isolation Tests
880    // ========================================================================
881
882    #[test]
883    fn test_volume_mounts_per_backend_instance_isolated() -> Result<()> {
884        // Arrange
885        let temp_dir = std::env::temp_dir();
886        let backend1_path = temp_dir.join("backend1");
887        let backend2_path = temp_dir.join("backend2");
888        std::fs::create_dir_all(&backend1_path)?;
889        std::fs::create_dir_all(&backend2_path)?;
890
891        let backend1 = TestcontainerBackend::new("alpine:latest")?.with_volume(
892            backend1_path.to_str().ok_or_else(|| {
893                crate::error::CleanroomError::internal_error("Invalid backend1 path")
894            })?,
895            "/data",
896            false,
897        )?;
898        let backend2 = TestcontainerBackend::new("alpine:latest")?.with_volume(
899            backend2_path.to_str().ok_or_else(|| {
900                crate::error::CleanroomError::internal_error("Invalid backend2 path")
901            })?,
902            "/data",
903            false,
904        )?;
905
906        // Assert - Each backend has independent volume configuration
907        assert_eq!(backend1.volume_mounts.len(), 1);
908        assert_eq!(backend2.volume_mounts.len(), 1);
909        assert_ne!(
910            backend1.volume_mounts[0].host_path(),
911            backend2.volume_mounts[0].host_path()
912        );
913
914        std::fs::remove_dir(&backend1_path)?;
915        std::fs::remove_dir(&backend2_path)?;
916        Ok(())
917    }
918
919    // ========================================================================
920    // Configuration Integration Tests
921    // ========================================================================
922
923    #[test]
924    fn test_volume_mounts_storage_format() -> Result<()> {
925        // Arrange
926        let temp_dir = std::env::temp_dir();
927        let host_path = temp_dir.join("storage_test");
928        std::fs::create_dir_all(&host_path)?;
929
930        let backend = TestcontainerBackend::new("alpine:latest")?.with_volume(
931            host_path
932                .to_str()
933                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid host path"))?,
934            "/container/path",
935            false,
936        )?;
937
938        // Assert - Verify internal storage format (VolumeMount)
939        assert_eq!(
940            backend.volume_mounts[0].container_path(),
941            std::path::Path::new("/container/path")
942        );
943
944        std::fs::remove_dir(&host_path)?;
945        Ok(())
946    }
947
948    #[test]
949    fn test_empty_volume_mounts_by_default() -> Result<()> {
950        // Arrange & Act
951        let backend = TestcontainerBackend::new("alpine:latest")?;
952
953        // Assert
954        assert!(backend.volume_mounts.is_empty());
955        Ok(())
956    }
957}