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 = "testcontainer.execute", skip(self, cmd), fields(image = %self.image_name, tag = %self.image_tag)))]
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        // Docker availability will be checked by the container startup itself
212
213        // Create base image
214        let image = GenericImage::new(self.image_name.clone(), self.image_tag.clone());
215
216        // Build container request with all configurations
217        let mut container_request: testcontainers::core::ContainerRequest<
218            testcontainers::GenericImage,
219        > = image.into();
220
221        // Add environment variables from backend storage
222        for (key, value) in &self.env_vars {
223            container_request = container_request.with_env_var(key, value);
224        }
225
226        // Add environment variables from command
227        for (key, value) in &cmd.env {
228            container_request = container_request.with_env_var(key, value);
229        }
230
231        // Add policy environment variables
232        for (key, value) in self.policy.to_env() {
233            container_request = container_request.with_env_var(key, value);
234        }
235
236        // Add volume mounts from backend storage
237        for mount in &self.volume_mounts {
238            use testcontainers::core::{AccessMode, Mount};
239
240            let access_mode = if mount.is_read_only() {
241                AccessMode::ReadOnly
242            } else {
243                AccessMode::ReadWrite
244            };
245
246            let bind_mount = Mount::bind_mount(
247                mount.host_path().to_string_lossy().to_string(),
248                mount.container_path().to_string_lossy().to_string(),
249            )
250            .with_access_mode(access_mode);
251
252            container_request = container_request.with_mount(bind_mount);
253        }
254
255        // Set a default command to keep the container running
256        // Alpine containers exit immediately without a command
257        container_request = container_request.with_cmd(vec!["sleep", "3600"]);
258
259        // Set working directory if specified
260        if let Some(workdir) = &cmd.workdir {
261            container_request =
262                container_request.with_working_dir(workdir.to_string_lossy().to_string());
263        }
264
265        // Start container using SyncRunner with timeout monitoring
266        let container_start_time = Instant::now();
267        let container = container_request
268            .start()
269            .map_err(|e| {
270                let elapsed = container_start_time.elapsed();
271                if elapsed > Duration::from_secs(10) {
272                    #[cfg(feature = "otel-traces")]
273                    warn!("Container startup took {}s, which is longer than expected. First pull of image may take time.", elapsed.as_secs());
274                }
275
276                BackendError::Runtime(format!(
277                    "Failed to start container with image '{}:{}' after {}s.\n\
278                    Possible causes:\n\
279                      - Docker daemon not running (try: docker ps)\n\
280                      - Image needs to be pulled (first run may take longer)\n\
281                      - Network issues preventing image pull\n\
282                    Try: Increase startup timeout or check Docker status\n\
283                    Original error: {}", 
284                    self.image_name, self.image_tag, elapsed.as_secs(), e
285                ))
286            })?;
287
288        #[cfg(feature = "otel-traces")]
289        info!("Container started successfully, executing command");
290
291        // Execute command - testcontainers expects Vec<&str> for exec
292        let cmd_args: Vec<&str> = std::iter::once(cmd.bin.as_str())
293            .chain(cmd.args.iter().map(|s| s.as_str()))
294            .collect();
295
296        let exec_cmd = ExecCommand::new(cmd_args);
297        let mut exec_result = container
298            .exec(exec_cmd)
299            .map_err(|e| BackendError::Runtime(format!("Command execution failed: {}", e)))?;
300
301        let duration_ms = start_time.elapsed().as_millis() as u64;
302
303        #[cfg(feature = "otel-traces")]
304        info!("Command completed in {}ms", duration_ms);
305
306        // Extract output - SyncExecResult provides stdout() and stderr() as streams
307        use std::io::Read;
308        let mut stdout = String::new();
309        let mut stderr = String::new();
310
311        exec_result
312            .stdout()
313            .read_to_string(&mut stdout)
314            .map_err(|e| BackendError::Runtime(format!("Failed to read stdout: {}", e)))?;
315        exec_result
316            .stderr()
317            .read_to_string(&mut stderr)
318            .map_err(|e| BackendError::Runtime(format!("Failed to read stderr: {}", e)))?;
319
320        let exit_code = exec_result.exit_code().unwrap_or(Some(-1)).unwrap_or(-1) as i32;
321
322        Ok(RunResult {
323            exit_code,
324            stdout,
325            stderr,
326            duration_ms,
327            steps: Vec::new(),
328            redacted_env: Vec::new(),
329            backend: "testcontainers".to_string(),
330            concurrent: false,
331            step_order: Vec::new(),
332        })
333    }
334}
335
336impl Backend for TestcontainerBackend {
337    fn run_cmd(&self, cmd: Cmd) -> Result<RunResult> {
338        // Use synchronous execution with timeout
339        let start_time = Instant::now();
340
341        // Execute command with timeout
342        let result = self.execute_in_container(&cmd)?;
343
344        // Check if execution exceeded timeout
345        if start_time.elapsed() > self.timeout {
346            return Err(crate::error::CleanroomError::timeout_error(format!(
347                "Command execution timed out after {} seconds",
348                self.timeout.as_secs()
349            )));
350        }
351
352        Ok(result)
353    }
354
355    fn name(&self) -> &str {
356        "testcontainers"
357    }
358
359    fn is_available(&self) -> bool {
360        Self::is_available()
361    }
362
363    fn supports_hermetic(&self) -> bool {
364        true
365    }
366
367    fn supports_deterministic(&self) -> bool {
368        true
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_testcontainer_backend_creation() {
378        let backend = TestcontainerBackend::new("alpine:latest");
379        assert!(backend.is_ok());
380    }
381
382    #[test]
383    fn test_testcontainer_backend_with_timeout() -> Result<()> {
384        let timeout = Duration::from_secs(60);
385        let backend = TestcontainerBackend::new("alpine:latest")?.with_timeout(timeout);
386        assert!(backend.is_running());
387        Ok(())
388    }
389
390    #[test]
391    fn test_testcontainer_backend_trait() -> Result<()> {
392        let backend = TestcontainerBackend::new("alpine:latest")?;
393        assert!(backend.is_running());
394        Ok(())
395    }
396
397    #[test]
398    fn test_testcontainer_backend_image() -> Result<()> {
399        let backend = TestcontainerBackend::new("ubuntu:20.04")?;
400        assert!(backend.is_running());
401        Ok(())
402    }
403}
404
405#[cfg(test)]
406mod volume_tests {
407    use super::*;
408    use crate::error::CleanroomError;
409
410    // ========================================================================
411    // Volume Mount Builder Tests
412    // ========================================================================
413
414    #[test]
415    fn test_with_volume_adds_mount_to_backend() -> Result<()> {
416        // Arrange
417        let temp_dir = std::env::temp_dir();
418        let host_path = temp_dir.join("test_mount");
419        std::fs::create_dir_all(&host_path)?;
420
421        let backend = TestcontainerBackend::new("alpine:latest")?;
422
423        // Act
424        let backend_with_volume = backend.with_volume(
425            host_path.to_str().ok_or_else(|| {
426                CleanroomError::internal_error("Invalid host path - contains non-UTF8 characters")
427            })?,
428            "/container/path",
429            false,
430        )?;
431
432        // Assert
433        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
434        assert_eq!(
435            backend_with_volume.volume_mounts[0]
436                .container_path()
437                .to_str()
438                .unwrap_or("invalid"),
439            "/container/path"
440        );
441
442        std::fs::remove_dir(&host_path)?;
443        Ok(())
444    }
445
446    #[test]
447    fn test_with_volume_supports_multiple_volumes() -> Result<()> {
448        // Arrange
449        let temp_dir = std::env::temp_dir();
450        let data_path = temp_dir.join("data");
451        let config_path = temp_dir.join("config");
452        let output_path = temp_dir.join("output");
453
454        std::fs::create_dir_all(&data_path)?;
455        std::fs::create_dir_all(&config_path)?;
456        std::fs::create_dir_all(&output_path)?;
457
458        let backend = TestcontainerBackend::new("alpine:latest")?;
459
460        // Act
461        let backend_with_volumes = backend
462            .with_volume(
463                data_path.to_str().ok_or_else(|| {
464                    crate::error::CleanroomError::internal_error("Invalid data path")
465                })?,
466                "/data",
467                false,
468            )?
469            .with_volume(
470                config_path.to_str().ok_or_else(|| {
471                    crate::error::CleanroomError::internal_error("Invalid config path")
472                })?,
473                "/config",
474                false,
475            )?
476            .with_volume(
477                output_path.to_str().ok_or_else(|| {
478                    crate::error::CleanroomError::internal_error("Invalid output path")
479                })?,
480                "/output",
481                false,
482            )?;
483
484        // Assert
485        assert_eq!(backend_with_volumes.volume_mounts.len(), 3);
486
487        std::fs::remove_dir(&data_path)?;
488        std::fs::remove_dir(&config_path)?;
489        std::fs::remove_dir(&output_path)?;
490        Ok(())
491    }
492
493    #[test]
494    fn test_with_volume_preserves_other_settings() -> Result<()> {
495        // Arrange
496        let temp_dir = std::env::temp_dir();
497        let test_path = temp_dir.join("test");
498        std::fs::create_dir_all(&test_path)?;
499
500        let timeout = Duration::from_secs(120);
501        let backend = TestcontainerBackend::new("alpine:latest")?
502            .with_timeout(timeout)
503            .with_env("TEST_VAR", "test_value");
504
505        // Act
506        let backend_with_volume = backend.with_volume(
507            test_path
508                .to_str()
509                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid test path"))?,
510            "/test",
511            false,
512        )?;
513
514        // Assert
515        assert_eq!(backend_with_volume.timeout, timeout);
516        assert_eq!(
517            backend_with_volume.env_vars.get("TEST_VAR"),
518            Some(&"test_value".to_string())
519        );
520        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
521
522        std::fs::remove_dir(&test_path)?;
523        Ok(())
524    }
525
526    // ========================================================================
527    // Path Validation Tests
528    // ========================================================================
529
530    #[test]
531    fn test_with_volume_accepts_absolute_host_paths() -> Result<()> {
532        // Arrange
533        let temp_dir = std::env::temp_dir();
534        let abs_path = temp_dir.join("absolute");
535        std::fs::create_dir_all(&abs_path)?;
536
537        let backend = TestcontainerBackend::new("alpine:latest")?;
538
539        // Act
540        let backend_with_volume = backend.with_volume(
541            abs_path.to_str().ok_or_else(|| {
542                crate::error::CleanroomError::internal_error("Invalid absolute path")
543            })?,
544            "/container/path",
545            false,
546        )?;
547
548        // Assert
549        assert!(backend_with_volume.volume_mounts[0]
550            .host_path()
551            .is_absolute());
552
553        std::fs::remove_dir(&abs_path)?;
554        Ok(())
555    }
556
557    #[test]
558    fn test_with_volume_rejects_relative_container_paths() -> Result<()> {
559        // Arrange
560        let temp_dir = std::env::temp_dir();
561        let backend = TestcontainerBackend::new("alpine:latest")?;
562
563        // Act - Container paths must be absolute now
564        let result = backend.with_volume(
565            temp_dir.to_str().ok_or_else(|| {
566                crate::error::CleanroomError::internal_error("Invalid temp dir path")
567            })?,
568            "relative/container/path",
569            false,
570        );
571
572        // Assert - Should fail validation
573        assert!(result.is_err());
574        Ok(())
575    }
576
577    #[test]
578    fn test_with_volume_handles_special_characters_in_paths() -> Result<()> {
579        // Arrange
580        let temp_dir = std::env::temp_dir();
581        let special_path = temp_dir.join("test-data_v1.0");
582        std::fs::create_dir_all(&special_path)?;
583
584        let backend = TestcontainerBackend::new("alpine:latest")?;
585
586        // Act - Paths with dashes, underscores
587        let backend_with_volume = backend.with_volume(
588            special_path.to_str().ok_or_else(|| {
589                crate::error::CleanroomError::internal_error("Invalid special path")
590            })?,
591            "/container/test-data/v1.0",
592            false,
593        )?;
594
595        // Assert
596        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
597
598        std::fs::remove_dir(&special_path)?;
599        Ok(())
600    }
601
602    #[test]
603    fn test_with_volume_rejects_empty_strings() -> Result<()> {
604        // Arrange
605        let backend = TestcontainerBackend::new("alpine:latest")?;
606
607        // Act - Empty paths should fail validation
608        let result = backend.with_volume("", "", false);
609
610        // Assert
611        assert!(result.is_err());
612        Ok(())
613    }
614
615    // ========================================================================
616    // Builder Pattern Tests
617    // ========================================================================
618
619    #[test]
620    fn test_volume_builder_chain_with_other_methods() -> Result<()> {
621        // Arrange & Act
622        let temp_dir = std::env::temp_dir();
623        let data_path = temp_dir.join("data_chain");
624        std::fs::create_dir_all(&data_path)?;
625
626        let backend = TestcontainerBackend::new("alpine:latest")?
627            .with_policy(Policy::default())
628            .with_timeout(Duration::from_secs(60))
629            .with_env("ENV_VAR", "value")
630            .with_volume(
631                data_path.to_str().ok_or_else(|| {
632                    CleanroomError::internal_error("Invalid path for volume mount")
633                })?,
634                "/data",
635                false,
636            )?
637            .with_memory_limit(512)
638            .with_cpu_limit(1.0);
639
640        // Assert
641        assert_eq!(backend.volume_mounts.len(), 1);
642        assert_eq!(backend.timeout, Duration::from_secs(60));
643        assert_eq!(backend.memory_limit, Some(512));
644        assert_eq!(backend.cpu_limit, Some(1.0));
645
646        std::fs::remove_dir(&data_path)?;
647        Ok(())
648    }
649
650    #[test]
651    fn test_volume_builder_immutability() -> Result<()> {
652        // Arrange
653        let temp_dir = std::env::temp_dir();
654        let test1_path = temp_dir.join("test1");
655        let test2_path = temp_dir.join("test2");
656        std::fs::create_dir_all(&test1_path)?;
657        std::fs::create_dir_all(&test2_path)?;
658
659        let backend1 = TestcontainerBackend::new("alpine:latest")?;
660
661        // Act
662        let backend2 = backend1.clone().with_volume(
663            test1_path.to_str().ok_or_else(|| {
664                crate::error::CleanroomError::internal_error("Invalid test1 path")
665            })?,
666            "/test1",
667            false,
668        )?;
669        let backend3 = backend1.clone().with_volume(
670            test2_path.to_str().ok_or_else(|| {
671                crate::error::CleanroomError::internal_error("Invalid test2 path")
672            })?,
673            "/test2",
674            false,
675        )?;
676
677        // Assert - Each chain creates independent backend
678        assert_eq!(backend2.volume_mounts.len(), 1);
679        assert_eq!(backend3.volume_mounts.len(), 1);
680        assert_ne!(
681            backend2.volume_mounts[0].container_path(),
682            backend3.volume_mounts[0].container_path()
683        );
684
685        std::fs::remove_dir(&test1_path)?;
686        std::fs::remove_dir(&test2_path)?;
687        Ok(())
688    }
689
690    // ========================================================================
691    // Edge Cases
692    // ========================================================================
693
694    #[test]
695    fn test_with_volume_duplicate_mounts_allowed() -> Result<()> {
696        // Arrange
697        let temp_dir = std::env::temp_dir();
698        let data_path = temp_dir.join("data_dup");
699        std::fs::create_dir_all(&data_path)?;
700
701        let backend = TestcontainerBackend::new("alpine:latest")?;
702
703        // Act - Same mount added twice
704        let backend_with_volumes = backend
705            .with_volume(
706                data_path.to_str().ok_or_else(|| {
707                    crate::error::CleanroomError::internal_error("Invalid data path")
708                })?,
709                "/data",
710                false,
711            )?
712            .with_volume(
713                data_path.to_str().ok_or_else(|| {
714                    crate::error::CleanroomError::internal_error("Invalid data path")
715                })?,
716                "/data",
717                false,
718            )?;
719
720        // Assert - Both mounts are added (Docker will handle duplicates)
721        assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
722
723        std::fs::remove_dir(&data_path)?;
724        Ok(())
725    }
726
727    #[test]
728    fn test_with_volume_overlapping_container_paths() -> Result<()> {
729        // Arrange
730        let temp_dir = std::env::temp_dir();
731        let data1_path = temp_dir.join("data1");
732        let data2_path = temp_dir.join("data2");
733        std::fs::create_dir_all(&data1_path)?;
734        std::fs::create_dir_all(&data2_path)?;
735
736        let backend = TestcontainerBackend::new("alpine:latest")?;
737
738        // Act - Different host paths to same container path
739        let backend_with_volumes = backend
740            .with_volume(
741                data1_path.to_str().ok_or_else(|| {
742                    crate::error::CleanroomError::internal_error("Invalid data1 path")
743                })?,
744                "/shared",
745                false,
746            )?
747            .with_volume(
748                data2_path.to_str().ok_or_else(|| {
749                    crate::error::CleanroomError::internal_error("Invalid data2 path")
750                })?,
751                "/shared",
752                false,
753            )?;
754
755        // Assert - Both mounts are added (last one wins in Docker)
756        assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
757
758        std::fs::remove_dir(&data1_path)?;
759        std::fs::remove_dir(&data2_path)?;
760        Ok(())
761    }
762
763    #[test]
764    fn test_with_volume_very_long_paths() -> Result<()> {
765        // Arrange - Create a directory with reasonable length
766        let temp_dir = std::env::temp_dir();
767        let long_name = "a".repeat(100); // Reasonable length
768        let long_path = temp_dir.join(long_name);
769        std::fs::create_dir_all(&long_path)?;
770
771        let backend = TestcontainerBackend::new("alpine:latest")?;
772
773        // Act
774        let backend_with_volume = backend.with_volume(
775            long_path
776                .to_str()
777                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid long path"))?,
778            "/data",
779            false,
780        )?;
781
782        // Assert
783        assert!(
784            backend_with_volume.volume_mounts[0]
785                .host_path()
786                .to_str()
787                .unwrap_or("")
788                .len()
789                > 100
790        );
791
792        std::fs::remove_dir(&long_path)?;
793        Ok(())
794    }
795
796    #[test]
797    #[cfg(target_os = "linux")]
798    fn test_with_volume_unicode_paths() -> Result<()> {
799        // Arrange - Unicode filenames (Linux/macOS support)
800        let temp_dir = std::env::temp_dir();
801        let unicode_path = temp_dir.join("données");
802        std::fs::create_dir_all(&unicode_path)?;
803
804        let backend = TestcontainerBackend::new("alpine:latest")?;
805
806        // Act - Unicode characters in paths
807        let backend_with_volume = backend.with_volume(
808            unicode_path.to_str().ok_or_else(|| {
809                crate::error::CleanroomError::internal_error("Invalid unicode path")
810            })?,
811            "/container/データ",
812            false,
813        )?;
814
815        // Assert
816        assert_eq!(backend_with_volume.volume_mounts.len(), 1);
817
818        std::fs::remove_dir(&unicode_path)?;
819        Ok(())
820    }
821
822    // ========================================================================
823    // Hermetic Isolation Tests
824    // ========================================================================
825
826    #[test]
827    fn test_volume_mounts_per_backend_instance_isolated() -> Result<()> {
828        // Arrange
829        let temp_dir = std::env::temp_dir();
830        let backend1_path = temp_dir.join("backend1");
831        let backend2_path = temp_dir.join("backend2");
832        std::fs::create_dir_all(&backend1_path)?;
833        std::fs::create_dir_all(&backend2_path)?;
834
835        let backend1 = TestcontainerBackend::new("alpine:latest")?.with_volume(
836            backend1_path.to_str().ok_or_else(|| {
837                crate::error::CleanroomError::internal_error("Invalid backend1 path")
838            })?,
839            "/data",
840            false,
841        )?;
842        let backend2 = TestcontainerBackend::new("alpine:latest")?.with_volume(
843            backend2_path.to_str().ok_or_else(|| {
844                crate::error::CleanroomError::internal_error("Invalid backend2 path")
845            })?,
846            "/data",
847            false,
848        )?;
849
850        // Assert - Each backend has independent volume configuration
851        assert_eq!(backend1.volume_mounts.len(), 1);
852        assert_eq!(backend2.volume_mounts.len(), 1);
853        assert_ne!(
854            backend1.volume_mounts[0].host_path(),
855            backend2.volume_mounts[0].host_path()
856        );
857
858        std::fs::remove_dir(&backend1_path)?;
859        std::fs::remove_dir(&backend2_path)?;
860        Ok(())
861    }
862
863    // ========================================================================
864    // Configuration Integration Tests
865    // ========================================================================
866
867    #[test]
868    fn test_volume_mounts_storage_format() -> Result<()> {
869        // Arrange
870        let temp_dir = std::env::temp_dir();
871        let host_path = temp_dir.join("storage_test");
872        std::fs::create_dir_all(&host_path)?;
873
874        let backend = TestcontainerBackend::new("alpine:latest")?.with_volume(
875            host_path
876                .to_str()
877                .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid host path"))?,
878            "/container/path",
879            false,
880        )?;
881
882        // Assert - Verify internal storage format (VolumeMount)
883        assert_eq!(
884            backend.volume_mounts[0].container_path(),
885            std::path::Path::new("/container/path")
886        );
887
888        std::fs::remove_dir(&host_path)?;
889        Ok(())
890    }
891
892    #[test]
893    fn test_empty_volume_mounts_by_default() -> Result<()> {
894        // Arrange & Act
895        let backend = TestcontainerBackend::new("alpine:latest")?;
896
897        // Assert
898        assert!(backend.volume_mounts.is_empty());
899        Ok(())
900    }
901}