nucleus/filesystem/
context.rs1use crate::error::{NucleusError, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5
6pub 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 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 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 self.copy_recursive(&self.source, &self.dest, 0)?;
43
44 info!("Successfully populated context");
45
46 Ok(())
47 }
48
49 pub fn validate_source_tree(&self) -> Result<()> {
54 Self::validate_source(&self.source)?;
55 self.validate_recursive(&self.source, 0)
56 }
57
58 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 const MAX_RECURSION_DEPTH: u32 = 128;
79
80 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 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 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 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 if lower == ".git" {
184 return true;
185 }
186
187 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 if name_str.starts_with(".env.") {
212 return true;
213 }
214
215 if name_str.ends_with(".swp") || name_str.ends_with(".swo") {
217 return true;
218 }
219
220 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 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 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 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 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 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}