Skip to main content

nucleus/filesystem/
context.rs

1use crate::error::{NucleusError, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5
6/// Context populator - copies files from source to destination
7pub struct ContextPopulator {
8    source: PathBuf,
9    dest: PathBuf,
10}
11
12impl ContextPopulator {
13    pub fn new<P: AsRef<Path>, Q: AsRef<Path>>(source: P, dest: Q) -> Self {
14        Self {
15            source: source.as_ref().to_path_buf(),
16            dest: dest.as_ref().to_path_buf(),
17        }
18    }
19
20    /// Populate the destination with files from source
21    ///
22    /// This implements the transition: mounted -> populated
23    pub fn populate(&self) -> Result<()> {
24        info!(
25            "Populating context from {:?} to {:?}",
26            self.source, self.dest
27        );
28
29        Self::validate_source(&self.source)?;
30
31        // Create destination if it doesn't exist
32        if !self.dest.exists() {
33            fs::create_dir_all(&self.dest).map_err(|e| {
34                NucleusError::ContextError(format!(
35                    "Failed to create destination {:?}: {}",
36                    self.dest, e
37                ))
38            })?;
39        }
40
41        // Walk source tree and copy (depth-limited to prevent stack overflow)
42        self.copy_recursive(&self.source, &self.dest, 0)?;
43
44        info!("Successfully populated context");
45
46        Ok(())
47    }
48
49    /// Validate a source tree without copying it.
50    ///
51    /// Used by bind-mount mode so the host tree gets the same preflight checks
52    /// as copy mode.
53    pub fn validate_source_tree(&self) -> Result<()> {
54        Self::validate_source(&self.source)?;
55        self.validate_recursive(&self.source, 0)
56    }
57
58    /// Validate that a source path exists and is a directory.
59    fn validate_source(source: &Path) -> Result<()> {
60        if !source.exists() {
61            return Err(NucleusError::ContextError(format!(
62                "Source directory does not exist: {:?}",
63                source
64            )));
65        }
66
67        if !source.is_dir() {
68            return Err(NucleusError::ContextError(format!(
69                "Source is not a directory: {:?}",
70                source
71            )));
72        }
73
74        Ok(())
75    }
76
77    /// Maximum directory recursion depth to prevent stack overflow
78    const MAX_RECURSION_DEPTH: u32 = 128;
79
80    /// Read directory entries, filtering out excluded names.
81    ///
82    /// Shared between `copy_recursive` and `validate_recursive` to avoid
83    /// duplicating depth checks, read_dir error handling, and exclusion logic.
84    fn filtered_entries(
85        dir: &Path,
86        depth: u32,
87    ) -> Result<Vec<(PathBuf, std::ffi::OsString, fs::Metadata)>> {
88        if depth > Self::MAX_RECURSION_DEPTH {
89            return Err(NucleusError::ContextError(format!(
90                "Maximum directory depth ({}) exceeded at {:?}",
91                Self::MAX_RECURSION_DEPTH,
92                dir
93            )));
94        }
95
96        let entries = fs::read_dir(dir).map_err(|e| {
97            NucleusError::ContextError(format!("Failed to read directory {:?}: {}", dir, e))
98        })?;
99
100        let mut result = Vec::new();
101        for entry in entries {
102            let entry = entry.map_err(|e| {
103                NucleusError::ContextError(format!("Failed to read entry in {:?}: {}", dir, e))
104            })?;
105
106            let file_name = entry.file_name();
107            if Self::should_exclude_name(&file_name) {
108                debug!("Skipping excluded file: {:?}", file_name);
109                continue;
110            }
111
112            let src_path = entry.path();
113            let metadata = fs::symlink_metadata(&src_path).map_err(|e| {
114                NucleusError::ContextError(format!(
115                    "Failed to get metadata for {:?}: {}",
116                    src_path, e
117                ))
118            })?;
119
120            result.push((src_path, file_name, metadata));
121        }
122
123        Ok(result)
124    }
125
126    /// Recursively copy directory contents
127    fn copy_recursive(&self, src: &Path, dst: &Path, depth: u32) -> Result<()> {
128        for (src_path, file_name, metadata) in Self::filtered_entries(src, depth)? {
129            let dst_path = dst.join(&file_name);
130
131            if metadata.is_dir() {
132                fs::create_dir_all(&dst_path).map_err(|e| {
133                    NucleusError::ContextError(format!(
134                        "Failed to create directory {:?}: {}",
135                        dst_path, e
136                    ))
137                })?;
138                self.copy_recursive(&src_path, &dst_path, depth + 1)?;
139            } else if metadata.is_file() {
140                fs::copy(&src_path, &dst_path).map_err(|e| {
141                    NucleusError::ContextError(format!(
142                        "Failed to copy {:?} to {:?}: {}",
143                        src_path, dst_path, e
144                    ))
145                })?;
146            } else if metadata.is_symlink() {
147                // Skip symlinks entirely to prevent link-based escapes or host path leakage.
148                warn!("Skipping symlink in context: {:?}", src_path);
149            }
150        }
151
152        Ok(())
153    }
154
155    fn validate_recursive(&self, src: &Path, depth: u32) -> Result<()> {
156        for (src_path, _file_name, metadata) in Self::filtered_entries(src, depth)? {
157            if metadata.is_symlink() {
158                return Err(NucleusError::ContextError(format!(
159                    "Bind-mounted contexts may not contain symlinks: {:?}",
160                    src_path
161                )));
162            }
163
164            if metadata.is_dir() {
165                self.validate_recursive(&src_path, depth + 1)?;
166            } else if !metadata.is_file() {
167                return Err(NucleusError::ContextError(format!(
168                    "Bind-mounted contexts may not contain special files: {:?}",
169                    src_path
170                )));
171            }
172        }
173
174        Ok(())
175    }
176
177    /// Check if a file should be excluded from copying
178    pub(crate) fn should_exclude_name(name: &std::ffi::OsStr) -> bool {
179        let name_str = name.to_string_lossy();
180        let lower = name_str.to_lowercase();
181
182        // Exact matches (case-insensitive for .git to cover .Git/.GIT variants):
183        if lower == ".git" {
184            return true;
185        }
186
187        // Exact matches: build artifacts, caches, sensitive directories
188        if matches!(
189            name_str.as_ref(),
190            "target"
191                | "node_modules"
192                | ".DS_Store"
193                | "__pycache__"
194                | ".svn"
195                | ".env"
196                | ".ssh"
197                | ".gnupg"
198                | ".aws"
199                | ".azure"
200                | ".gcloud"
201                | ".config/gcloud"
202                | ".docker"
203                | ".netrc"
204                | ".kube"
205                | ".helm"
206        ) {
207            return true;
208        }
209
210        // Prefix patterns: .env.* files (e.g., .env.local, .env.production)
211        if name_str.starts_with(".env.") {
212            return true;
213        }
214
215        // Suffix patterns: editor swap files
216        if name_str.ends_with(".swp") || name_str.ends_with(".swo") {
217            return true;
218        }
219
220        // Suffix patterns: crypto material
221        if name_str.ends_with(".pem")
222            || name_str.ends_with(".key")
223            || name_str.ends_with(".p12")
224            || name_str.ends_with(".crt")
225            || name_str.ends_with(".pfx")
226            || name_str.ends_with(".jks")
227        {
228            return true;
229        }
230
231        // Contains patterns (case-insensitive): secrets and credentials
232        if lower.contains("credential")
233            || lower.contains("secret")
234            || lower.contains("private_key")
235            || lower.contains("kubeconfig")
236        {
237            return true;
238        }
239
240        false
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_should_exclude_exact_matches() {
250        // Original exact matches
251        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
252            ".git"
253        )));
254        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
255            "target"
256        )));
257        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
258            "node_modules"
259        )));
260        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
261            ".DS_Store"
262        )));
263        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
264            "__pycache__"
265        )));
266
267        // New exact matches
268        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
269            ".svn"
270        )));
271        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
272            ".env"
273        )));
274        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
275            ".ssh"
276        )));
277        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
278            ".gnupg"
279        )));
280        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
281            ".aws"
282        )));
283        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
284            ".docker"
285        )));
286
287        // L-2: expanded exclusion list
288        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
289            ".azure"
290        )));
291        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
292            ".gcloud"
293        )));
294        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
295            ".netrc"
296        )));
297        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
298            ".kube"
299        )));
300        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
301            ".helm"
302        )));
303    }
304
305    #[test]
306    fn test_should_exclude_env_variants() {
307        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
308            ".env.local"
309        )));
310        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
311            ".env.production"
312        )));
313        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
314            ".env.development"
315        )));
316    }
317
318    #[test]
319    fn test_should_exclude_editor_swap() {
320        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
321            "file.swp"
322        )));
323        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
324            "file.swo"
325        )));
326    }
327
328    #[test]
329    fn test_should_exclude_crypto_material() {
330        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
331            "server.pem"
332        )));
333        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
334            "private.key"
335        )));
336        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
337            "cert.p12"
338        )));
339        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
340            "ca.crt"
341        )));
342        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
343            "keystore.pfx"
344        )));
345        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
346            "app.jks"
347        )));
348    }
349
350    #[test]
351    fn test_should_exclude_secrets_patterns() {
352        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
353            "credentials.json"
354        )));
355        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
356            "my_secret.txt"
357        )));
358        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
359            "private_key.pem"
360        )));
361        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
362            "AWS_CREDENTIALS"
363        )));
364        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
365            "app-secret-config.yaml"
366        )));
367
368        // L-2: kubeconfig pattern
369        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
370            "kubeconfig"
371        )));
372        assert!(ContextPopulator::should_exclude_name(std::ffi::OsStr::new(
373            "my-kubeconfig.yaml"
374        )));
375    }
376
377    #[test]
378    fn test_should_not_exclude_legitimate_files() {
379        assert!(!ContextPopulator::should_exclude_name(
380            std::ffi::OsStr::new("src")
381        ));
382        assert!(!ContextPopulator::should_exclude_name(
383            std::ffi::OsStr::new("README.md")
384        ));
385        assert!(!ContextPopulator::should_exclude_name(
386            std::ffi::OsStr::new("main.rs")
387        ));
388        assert!(!ContextPopulator::should_exclude_name(
389            std::ffi::OsStr::new("Cargo.toml")
390        ));
391        assert!(!ContextPopulator::should_exclude_name(
392            std::ffi::OsStr::new("my_file.rs")
393        ));
394        assert!(!ContextPopulator::should_exclude_name(
395            std::ffi::OsStr::new("config.yaml")
396        ));
397    }
398}