fast-clean-architecture 1.0.0__py3-none-any.whl

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.
Files changed (30) hide show
  1. fast_clean_architecture/__init__.py +24 -0
  2. fast_clean_architecture/cli.py +480 -0
  3. fast_clean_architecture/config.py +506 -0
  4. fast_clean_architecture/exceptions.py +63 -0
  5. fast_clean_architecture/generators/__init__.py +11 -0
  6. fast_clean_architecture/generators/component_generator.py +1039 -0
  7. fast_clean_architecture/generators/config_updater.py +308 -0
  8. fast_clean_architecture/generators/package_generator.py +174 -0
  9. fast_clean_architecture/generators/template_validator.py +546 -0
  10. fast_clean_architecture/generators/validation_config.py +75 -0
  11. fast_clean_architecture/generators/validation_metrics.py +193 -0
  12. fast_clean_architecture/templates/__init__.py +7 -0
  13. fast_clean_architecture/templates/__init__.py.j2 +26 -0
  14. fast_clean_architecture/templates/api.py.j2 +65 -0
  15. fast_clean_architecture/templates/command.py.j2 +26 -0
  16. fast_clean_architecture/templates/entity.py.j2 +49 -0
  17. fast_clean_architecture/templates/external.py.j2 +61 -0
  18. fast_clean_architecture/templates/infrastructure_repository.py.j2 +69 -0
  19. fast_clean_architecture/templates/model.py.j2 +38 -0
  20. fast_clean_architecture/templates/query.py.j2 +26 -0
  21. fast_clean_architecture/templates/repository.py.j2 +57 -0
  22. fast_clean_architecture/templates/schemas.py.j2 +32 -0
  23. fast_clean_architecture/templates/service.py.j2 +109 -0
  24. fast_clean_architecture/templates/value_object.py.j2 +34 -0
  25. fast_clean_architecture/utils.py +553 -0
  26. fast_clean_architecture-1.0.0.dist-info/METADATA +541 -0
  27. fast_clean_architecture-1.0.0.dist-info/RECORD +30 -0
  28. fast_clean_architecture-1.0.0.dist-info/WHEEL +4 -0
  29. fast_clean_architecture-1.0.0.dist-info/entry_points.txt +2 -0
  30. fast_clean_architecture-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1039 @@
1
+ """Component generator for creating individual component files."""
2
+
3
+ import os
4
+ import re
5
+ import functools
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional, Union, List
9
+
10
+ import jinja2
11
+ from jinja2 import TemplateSyntaxError
12
+ from jinja2.exceptions import SecurityError
13
+ from jinja2.sandbox import SandboxedEnvironment
14
+ from rich.console import Console
15
+
16
+ from ..templates import TEMPLATES_DIR
17
+ from ..utils import (
18
+ get_template_variables,
19
+ to_pascal_case,
20
+ to_snake_case,
21
+ ensure_directory,
22
+ sanitize_name,
23
+ validate_python_identifier,
24
+ validate_name,
25
+ secure_file_operation,
26
+ create_secure_error,
27
+ sanitize_error_message,
28
+ )
29
+ from ..exceptions import (
30
+ TemplateError,
31
+ ValidationError,
32
+ FileConflictError,
33
+ ComponentError,
34
+ )
35
+ from ..config import Config
36
+ from .template_validator import TemplateValidatorFactory
37
+
38
+ # Set up logger for security events
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ComponentGenerator:
43
+ """Generator for creating individual component files from templates."""
44
+
45
+ # Mapping of component types to their template files
46
+ TEMPLATE_MAPPING = {
47
+ "entities": "entity.py.j2",
48
+ "repositories": "repository.py.j2",
49
+ "value_objects": "value_object.py.j2",
50
+ "services": "service.py.j2",
51
+ "commands": "command.py.j2",
52
+ "queries": "query.py.j2",
53
+ "models": "model.py.j2",
54
+ "external": "external.py.j2",
55
+ "api": "api.py.j2",
56
+ "schemas": "schemas.py.j2",
57
+ }
58
+
59
+ # Infrastructure repositories use a different template
60
+ INFRASTRUCTURE_REPOSITORY_TEMPLATE = "infrastructure_repository.py.j2"
61
+
62
+ def __init__(self, config: Config, console: Optional[Console] = None):
63
+ self.config = config
64
+ self.console = console or Console()
65
+ # Use SandboxedEnvironment for security
66
+ self.template_env = SandboxedEnvironment(
67
+ loader=jinja2.FileSystemLoader(TEMPLATES_DIR),
68
+ trim_blocks=True,
69
+ lstrip_blocks=True,
70
+ # Restrict available functions and filters for security
71
+ finalize=self._sanitize_template_output,
72
+ )
73
+ # Define allowed filters and functions
74
+ self._setup_sandbox_security()
75
+
76
+ # Initialize template validator with simplified validation config
77
+ from .validation_config import ValidationConfig
78
+
79
+ validation_config = ValidationConfig(
80
+ sandbox_mode=True,
81
+ max_template_size_bytes=64 * 1024, # 64KB limit
82
+ render_timeout_seconds=10,
83
+ max_variable_nesting_depth=10,
84
+ )
85
+ self.template_validator = TemplateValidatorFactory().create(
86
+ self.template_env, validation_config
87
+ )
88
+
89
+ def create_component(
90
+ self,
91
+ base_path: Path,
92
+ system_name: str,
93
+ module_name: str,
94
+ layer: str,
95
+ component_type: str,
96
+ component_name: str,
97
+ dry_run: bool = False,
98
+ force: bool = False,
99
+ ) -> Path:
100
+ """Create a single component file."""
101
+ # Validate inputs
102
+ self._validate_component_inputs(
103
+ system_name, module_name, layer, component_type, component_name
104
+ )
105
+
106
+ # Sanitize component name
107
+ sanitized_name = sanitize_name(component_name)
108
+ if not validate_python_identifier(sanitized_name):
109
+ raise ValidationError(f"Invalid component name: {component_name}")
110
+
111
+ # Determine file path and name
112
+ file_path, file_name = self._get_component_file_path(
113
+ base_path, system_name, module_name, layer, component_type, sanitized_name
114
+ )
115
+
116
+ # Check for conflicts
117
+ if file_path.exists() and not force:
118
+ raise FileConflictError(
119
+ f"Component file already exists: {file_path}. Use --force to overwrite."
120
+ )
121
+
122
+ if dry_run:
123
+ self.console.print(f"[yellow]DRY RUN:[/yellow] Would create {file_path}")
124
+ return file_path
125
+
126
+ # Validate file system permissions and space
127
+ self._validate_file_system(file_path)
128
+
129
+ # Ensure directory exists
130
+ ensure_directory(file_path.parent)
131
+
132
+ # Generate template variables
133
+ template_vars = get_template_variables(
134
+ system_name=system_name,
135
+ module_name=module_name,
136
+ component_name=sanitized_name,
137
+ component_type=component_type,
138
+ )
139
+
140
+ # Get template name
141
+ template_name = self._get_template_name(layer, component_type)
142
+
143
+ # Validate template variables
144
+ self._validate_template_variables(template_name, template_vars)
145
+
146
+ # Check for custom template
147
+ custom_template = getattr(self.config.templates, component_type, None)
148
+ if custom_template:
149
+ template_name = custom_template
150
+
151
+ # Render and write file
152
+ content = self._render_template(template_name, template_vars)
153
+
154
+ # Write file with atomic operations and error handling
155
+ backup_path = None
156
+ lock_file = file_path.with_suffix(f"{file_path.suffix}.lock")
157
+
158
+ try:
159
+ # Use file locking to prevent race conditions
160
+ import fcntl
161
+ import tempfile
162
+
163
+ # Create lock file for atomic operations
164
+ with open(lock_file, "w") as lock:
165
+ try:
166
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
167
+ except (IOError, OSError):
168
+ raise ValidationError(
169
+ f"File {file_path} is locked by another process"
170
+ )
171
+
172
+ # Create backup if file exists (within lock)
173
+ if file_path.exists():
174
+ backup_path = file_path.with_suffix(f"{file_path.suffix}.backup")
175
+ # Use atomic copy to prevent corruption
176
+ with open(file_path, "rb") as src, open(backup_path, "wb") as dst:
177
+ dst.write(src.read())
178
+
179
+ # Atomic write operation to prevent TOCTOU vulnerabilities
180
+ self._atomic_write_file(file_path, content)
181
+
182
+ # Clean up backup on success
183
+ if backup_path and backup_path.exists():
184
+ backup_path.unlink()
185
+
186
+ except Exception as e:
187
+ # Rollback on failure
188
+ if backup_path and backup_path.exists():
189
+ try:
190
+ if file_path.exists():
191
+ file_path.unlink()
192
+ backup_path.rename(file_path)
193
+ except Exception as rollback_error:
194
+ self.console.print(
195
+ f"[red]Error during rollback: {rollback_error}[/red]"
196
+ )
197
+
198
+ if isinstance(e, (ComponentError, ValidationError)):
199
+ raise
200
+ raise ValidationError(f"Failed to write file: {e}")
201
+ finally:
202
+ # Always clean up lock file
203
+ if lock_file.exists():
204
+ try:
205
+ lock_file.unlink()
206
+ except Exception:
207
+ pass # Ignore cleanup errors
208
+
209
+ self.console.print(f"[green]✓[/green] Created {file_path}")
210
+ # Ensure we're returning a valid Path object
211
+ if not isinstance(file_path, Path):
212
+ file_path = Path(file_path)
213
+ return file_path
214
+
215
+ def _atomic_write_file(self, file_path: Path, content: str) -> None:
216
+ """Write file atomically to prevent corruption and TOCTOU vulnerabilities.
217
+
218
+ Uses temporary file in same directory and atomic rename to ensure:
219
+ 1. No partial writes visible to other processes
220
+ 2. No race conditions between check and write
221
+ 3. Proper cleanup on failure
222
+ 4. File locking to prevent concurrent access
223
+ """
224
+ # Ensure we have absolute path
225
+ file_path = file_path.resolve()
226
+
227
+ # Use secure file operation with locking
228
+ return secure_file_operation(
229
+ file_path, self._perform_atomic_write, file_path, content
230
+ )
231
+
232
+ def _perform_atomic_write(self, file_path: Path, content: str) -> None:
233
+ """Perform the actual atomic write operation."""
234
+ import tempfile
235
+ import os
236
+
237
+ # Validate file_path and ensure it's a proper Path object
238
+ if not isinstance(file_path, Path):
239
+ file_path = Path(file_path)
240
+
241
+ # Ensure we have a valid parent directory
242
+ parent_dir = file_path.parent
243
+ if parent_dir is None or str(parent_dir) == ".":
244
+ parent_dir = Path.cwd()
245
+
246
+ # Ensure parent directory exists with proper error handling
247
+ try:
248
+ parent_dir.mkdir(parents=True, exist_ok=True)
249
+ except OSError as e:
250
+ raise create_secure_error("directory_creation", "create directory", str(e))
251
+
252
+ # Use atomic write with temporary file in same directory
253
+ temp_fd = None
254
+ temp_path = None
255
+ try:
256
+ # Create temporary file in same directory as target
257
+ temp_fd, temp_path = tempfile.mkstemp(
258
+ dir=parent_dir, prefix=f".{file_path.name}.", suffix=".tmp"
259
+ )
260
+
261
+ # Write content to temporary file
262
+ with os.fdopen(temp_fd, "w", encoding="utf-8") as temp_file:
263
+ temp_file.write(content)
264
+ temp_file.flush()
265
+ os.fsync(temp_file.fileno()) # Ensure data is written to disk
266
+ temp_fd = None # File descriptor is now closed
267
+
268
+ # Atomically move temporary file to target location
269
+ temp_path_obj = Path(temp_path)
270
+ temp_path_obj.replace(file_path)
271
+ temp_path = None # Successfully moved, don't clean up
272
+
273
+ # Verify file was written correctly
274
+ if not file_path.exists() or file_path.stat().st_size == 0:
275
+ raise create_secure_error(
276
+ "file_write", "write file", "verification failed"
277
+ )
278
+
279
+ except OSError as e:
280
+ # Handle permission errors and other OS-level issues
281
+ if e.errno == 13: # Permission denied
282
+ raise create_secure_error(
283
+ "file_write", "write file", "permission denied"
284
+ )
285
+ elif e.errno == 28: # No space left on device
286
+ raise create_secure_error(
287
+ "file_write", "write file", "insufficient disk space"
288
+ )
289
+ else:
290
+ raise create_secure_error("file_write", "write file", str(e))
291
+ finally:
292
+ # Clean up on failure
293
+ if temp_fd is not None:
294
+ try:
295
+ os.close(temp_fd)
296
+ except OSError:
297
+ pass
298
+ if temp_path and Path(temp_path).exists():
299
+ try:
300
+ Path(temp_path).unlink()
301
+ except OSError:
302
+ pass
303
+
304
+ def create_multiple_components(
305
+ self,
306
+ base_path: Path,
307
+ system_name: str,
308
+ module_name: str,
309
+ components_spec: Dict[str, Dict[str, list]],
310
+ dry_run: bool = False,
311
+ force: bool = False,
312
+ ) -> list[Path]:
313
+ """Create multiple components from specification.
314
+
315
+ Args:
316
+ components_spec: Dict like {
317
+ "domain": {"entities": ["user", "order"], "repositories": ["user"]},
318
+ "application": {"services": ["user_service"]}
319
+ }
320
+ """
321
+ created_files = []
322
+
323
+ for layer, layer_components in components_spec.items():
324
+ for component_type, component_names in layer_components.items():
325
+ for component_name in component_names:
326
+ try:
327
+ file_path = self.create_component(
328
+ base_path=base_path,
329
+ system_name=system_name,
330
+ module_name=module_name,
331
+ layer=layer,
332
+ component_type=component_type,
333
+ component_name=component_name,
334
+ dry_run=dry_run,
335
+ force=force,
336
+ )
337
+ created_files.append(file_path)
338
+ except Exception as e:
339
+ self.console.print(
340
+ f"[red]Error creating {component_name}:[/red] {e}"
341
+ )
342
+ if not force:
343
+ raise
344
+
345
+ return created_files
346
+
347
+ def _validate_component_inputs(
348
+ self,
349
+ system_name: str,
350
+ module_name: str,
351
+ layer: str,
352
+ component_type: str,
353
+ component_name: str,
354
+ ) -> None:
355
+ """Validate component creation inputs."""
356
+ # Validate system name
357
+ try:
358
+ validate_name(system_name)
359
+ except (ValueError, TypeError) as e:
360
+ raise ValidationError(f"Invalid system name: {e}")
361
+
362
+ # Validate module name
363
+ try:
364
+ validate_name(module_name)
365
+ except (ValueError, TypeError) as e:
366
+ raise ValidationError(f"Invalid module name: {e}")
367
+
368
+ # Validate component name
369
+ try:
370
+ validate_name(component_name)
371
+ except (ValueError, TypeError) as e:
372
+ raise ValidationError(f"Invalid component name: {e}")
373
+
374
+ valid_layers = ["domain", "application", "infrastructure", "presentation"]
375
+ if layer not in valid_layers:
376
+ raise ValidationError(
377
+ f"Invalid layer: {layer}. Must be one of {valid_layers}"
378
+ )
379
+
380
+ layer_components = {
381
+ "domain": ["entities", "repositories", "value_objects"],
382
+ "application": ["services", "commands", "queries"],
383
+ "infrastructure": ["models", "repositories", "external", "internal"],
384
+ "presentation": ["api", "schemas"],
385
+ }
386
+
387
+ if component_type not in layer_components[layer]:
388
+ raise ValidationError(
389
+ f"Invalid component type '{component_type}' for layer '{layer}'. "
390
+ f"Valid types: {layer_components[layer]}"
391
+ )
392
+
393
+ if not component_name.strip():
394
+ raise ValidationError("Component name cannot be empty")
395
+
396
+ def _get_component_file_path(
397
+ self,
398
+ base_path: Path,
399
+ system_name: str,
400
+ module_name: str,
401
+ layer: str,
402
+ component_type: str,
403
+ component_name: str,
404
+ ) -> tuple[Path, str]:
405
+ """Get the file path and name for a component."""
406
+ # Determine file name based on component type
407
+ file_name = self._get_component_file_name(component_type, component_name)
408
+
409
+ # Build full path
410
+ file_path = (
411
+ base_path
412
+ / "systems"
413
+ / system_name
414
+ / module_name
415
+ / layer
416
+ / component_type
417
+ / file_name
418
+ )
419
+
420
+ return file_path, file_name
421
+
422
+ def _get_component_file_name(self, component_type: str, component_name: str) -> str:
423
+ """Get the file name for a component based on its type."""
424
+ snake_name = to_snake_case(component_name)
425
+
426
+ file_name_patterns = {
427
+ "entities": f"{snake_name}.py",
428
+ "repositories": f"{snake_name}_repository.py",
429
+ "value_objects": f"{snake_name}.py",
430
+ "services": f"{snake_name}_service.py",
431
+ "commands": f"{snake_name}.py",
432
+ "queries": f"{snake_name}.py",
433
+ "models": f"{snake_name}_model.py",
434
+ "external": f"{snake_name}_client.py",
435
+ "internal": f"{snake_name}.py",
436
+ "api": f"{snake_name}_router.py",
437
+ "schemas": f"{snake_name}_schemas.py",
438
+ }
439
+
440
+ return file_name_patterns.get(component_type, f"{snake_name}.py")
441
+
442
+ def _get_template_name(self, layer: str, component_type: str) -> str:
443
+ """Get the template name for a component."""
444
+ # Special case for infrastructure repositories
445
+ if layer == "infrastructure" and component_type == "repositories":
446
+ return self.INFRASTRUCTURE_REPOSITORY_TEMPLATE
447
+
448
+ return self.TEMPLATE_MAPPING.get(component_type, "entity.py.j2")
449
+
450
+ def _validate_template_variables(
451
+ self, template_content_or_name: str, template_vars: Dict[str, Any]
452
+ ) -> None:
453
+ """Validate that all required template variables are available.
454
+
455
+ Args:
456
+ template_content_or_name: Template content or filename
457
+ template_vars: Variables to validate against
458
+
459
+ Raises:
460
+ TemplateError: If validation fails
461
+ """
462
+ self.template_validator.validate(template_content_or_name, template_vars)
463
+
464
+ def _validate_file_system(self, file_path: Path) -> None:
465
+ """Validate file system security and basic requirements."""
466
+ import shutil
467
+ import os
468
+
469
+ try:
470
+ # Prevent symlink attacks by checking the entire path
471
+ self._check_symlink_attack(file_path)
472
+
473
+ # Basic permission check (not relied upon for security due to TOCTOU)
474
+ # This is kept for early validation and test compatibility
475
+ parent_dir = file_path.parent
476
+ if parent_dir.exists():
477
+ if not os.access(parent_dir, os.W_OK):
478
+ raise create_secure_error(
479
+ "permission_denied",
480
+ "access directory",
481
+ "write permission denied",
482
+ )
483
+
484
+ # Check available disk space (require at least 1MB)
485
+ # Use parent directory or current directory for disk space check
486
+ check_dir = file_path.parent if file_path.parent.exists() else Path.cwd()
487
+ try:
488
+ disk_usage = shutil.disk_usage(check_dir)
489
+ free_space = (
490
+ disk_usage[2] if isinstance(disk_usage, tuple) else disk_usage.free
491
+ )
492
+ if free_space < 1024 * 1024: # 1MB
493
+ raise ValidationError("Insufficient disk space")
494
+ except OSError:
495
+ # If we can't check disk space, continue but warn
496
+ import logging
497
+
498
+ logging.warning("Could not check disk space")
499
+
500
+ except (ComponentError, ValidationError):
501
+ raise
502
+ except Exception as e:
503
+ raise create_secure_error(
504
+ "file_system_validation", "validate file system", str(e)
505
+ )
506
+
507
+ def _check_symlink_attack(self, file_path: Path) -> None:
508
+ """Check for potential symlink attacks in the file path.
509
+
510
+ This method prevents symlink attacks by ensuring that no part of the path
511
+ contains symbolic links that could redirect file creation outside the
512
+ intended directory structure.
513
+ """
514
+ try:
515
+ # Use strict=True to fail on broken symlinks and detect symlink issues early
516
+ try:
517
+ resolved_path = file_path.resolve(strict=True)
518
+ except (OSError, RuntimeError) as e:
519
+ # If strict resolution fails, try non-strict and validate manually
520
+ resolved_path = file_path.resolve(strict=False)
521
+ # Additional validation: check if any component is a broken symlink
522
+ current = file_path
523
+ while current != current.parent:
524
+ if current.is_symlink() and not current.exists():
525
+ raise ValidationError(
526
+ f"Broken symlink detected in path: {current}. "
527
+ "File creation through broken symlinks is not allowed."
528
+ )
529
+ current = current.parent
530
+
531
+ # More robust symlink detection - check each component of the original path
532
+ current_path = file_path
533
+ while current_path != current_path.parent:
534
+ if current_path.exists() and current_path.is_symlink():
535
+ # Instead of hardcoded allowlist, use more sophisticated validation
536
+ symlink_target = current_path.readlink()
537
+
538
+ # Check if symlink points outside the project or to dangerous locations
539
+ if symlink_target.is_absolute():
540
+ # Absolute symlinks are generally more dangerous
541
+ if not self._is_safe_system_symlink(
542
+ current_path, symlink_target
543
+ ):
544
+ raise ValidationError(
545
+ f"Potentially unsafe symlink detected: {current_path} -> {symlink_target}. "
546
+ "File creation through unsafe symlinks is not allowed for security reasons."
547
+ )
548
+ else:
549
+ # Relative symlinks - resolve and check if they stay within bounds
550
+ try:
551
+ resolved_target = (
552
+ current_path.parent / symlink_target
553
+ ).resolve(strict=True)
554
+ if not self._is_path_within_safe_bounds(resolved_target):
555
+ raise ValidationError(
556
+ f"Symlink points outside safe boundaries: {current_path} -> {resolved_target}. "
557
+ "File creation through such symlinks is not allowed."
558
+ )
559
+ except (OSError, RuntimeError):
560
+ raise ValidationError(
561
+ f"Invalid symlink target: {current_path} -> {symlink_target}. "
562
+ "File creation through invalid symlinks is not allowed."
563
+ )
564
+
565
+ current_path = current_path.parent
566
+
567
+ # Enhanced temp directory detection with more robust cross-platform support
568
+ if (
569
+ not self._is_temp_path(resolved_path)
570
+ and "systems" not in resolved_path.parts
571
+ ):
572
+ raise ValidationError(
573
+ f"Invalid file path: {file_path}. "
574
+ "Component files must be created within the systems directory structure."
575
+ )
576
+
577
+ # Additional validation after path resolution
578
+ self._validate_resolved_path(file_path, resolved_path)
579
+
580
+ except ValidationError:
581
+ # Re-raise validation errors as-is
582
+ raise
583
+ except (OSError, RuntimeError) as e:
584
+ # Handle cases where path resolution fails
585
+ raise create_secure_error("path_validation", "validate path", str(e))
586
+
587
+ def _is_safe_system_symlink(self, symlink_path: Path, target_path: Path) -> bool:
588
+ """Check if a symlink is a safe system symlink.
589
+
590
+ This method uses more sophisticated logic than hardcoded allowlists
591
+ to determine if a symlink is safe.
592
+ """
593
+ symlink_str = str(symlink_path)
594
+ target_str = str(target_path)
595
+
596
+ # Common safe system symlink patterns across platforms
597
+ safe_patterns = [
598
+ # Unix/Linux system symlinks
599
+ ("/var", "/private/var"), # macOS /var -> /private/var
600
+ ("/tmp", "/private/tmp"), # macOS /tmp -> /private/tmp
601
+ ("/etc", "/private/etc"), # macOS /etc -> /private/etc
602
+ # Other common system symlinks
603
+ ("/usr/bin", "/bin"),
604
+ ("/usr/lib", "/lib"),
605
+ ]
606
+
607
+ # Check if this matches known safe system symlink patterns
608
+ for safe_source, safe_target in safe_patterns:
609
+ if symlink_str.startswith(safe_source) and target_str.startswith(
610
+ safe_target
611
+ ):
612
+ return True
613
+
614
+ # Additional checks for system directories
615
+ system_dirs = {"/usr", "/bin", "/lib", "/sbin", "/opt", "/System", "/Library"}
616
+ if any(symlink_str.startswith(d) for d in system_dirs) and any(
617
+ target_str.startswith(d) for d in system_dirs
618
+ ):
619
+ return True
620
+
621
+ return False
622
+
623
+ def _is_path_within_safe_bounds(self, path: Path) -> bool:
624
+ """Check if a resolved path is within safe boundaries."""
625
+ path_str = str(path)
626
+
627
+ # Paths that should never be accessible
628
+ dangerous_paths = {
629
+ "/etc/passwd",
630
+ "/etc/shadow",
631
+ "/etc/hosts",
632
+ "/root",
633
+ "/boot",
634
+ "/proc",
635
+ "/sys",
636
+ "/dev",
637
+ }
638
+
639
+ # Check for exact matches or if path starts with dangerous directories
640
+ for dangerous in dangerous_paths:
641
+ if path_str == dangerous or path_str.startswith(dangerous + "/"):
642
+ return False
643
+
644
+ return True
645
+
646
+ def _is_temp_path(self, path: Union[Path, str]) -> bool:
647
+ """Enhanced cross-platform temporary directory detection with caching.
648
+
649
+ Args:
650
+ path: Path to check (accepts both Path objects and strings)
651
+
652
+ Returns:
653
+ bool: True if path is detected as temporary, False otherwise
654
+ """
655
+ # Convert string to Path if needed
656
+ if isinstance(path, str):
657
+ path = Path(path)
658
+
659
+ # Use cached version for performance
660
+ result = self._is_temp_path_cached(str(path))
661
+
662
+ # Log security events for temp path access
663
+ if result:
664
+ logger.debug(f"Temporary path access detected: {path}")
665
+
666
+ return result
667
+
668
+ @functools.lru_cache(maxsize=128)
669
+ def _is_temp_path_cached(self, path_str: str) -> bool:
670
+ """Cached implementation of temporary path detection."""
671
+ import tempfile
672
+ import os
673
+
674
+ path = Path(path_str)
675
+
676
+ try:
677
+ # Primary method: Use system's temp directory detection
678
+ system_temp = Path(tempfile.gettempdir()).resolve()
679
+
680
+ # Check if path is relative to system temp directory
681
+ try:
682
+ if path.resolve().is_relative_to(system_temp):
683
+ return True
684
+ except (AttributeError, ValueError):
685
+ # Fallback for older Python versions or path resolution issues
686
+ if str(path.resolve()).startswith(str(system_temp)):
687
+ return True
688
+
689
+ except (OSError, ValueError):
690
+ pass
691
+
692
+ # Always check fallback patterns if primary method didn't match
693
+ return self._fallback_temp_detection(path)
694
+
695
+ def _fallback_temp_detection(self, path: Path) -> bool:
696
+ """Enhanced fallback temporary directory detection with configurable patterns.
697
+
698
+ Args:
699
+ path: Path object to check for temporary directory patterns
700
+
701
+ Returns:
702
+ bool: True if path matches temporary directory patterns
703
+ """
704
+ import os
705
+
706
+ path_str = str(path).lower()
707
+
708
+ # Check environment variables
709
+ temp_env_vars = ["TMPDIR", "TEMP", "TMP"]
710
+ for env_var in temp_env_vars:
711
+ if env_var in os.environ:
712
+ try:
713
+ env_temp = Path(os.environ[env_var]).resolve()
714
+ if str(path).startswith(str(env_temp)):
715
+ return True
716
+ except (ValueError, OSError):
717
+ continue
718
+
719
+ # Check for pytest temp directories (common in testing)
720
+ if "pytest-of-" in path_str:
721
+ return True
722
+
723
+ # Get custom temp patterns from config if available
724
+ custom_patterns = self._get_custom_temp_patterns()
725
+
726
+ # Check for common temp directory patterns
727
+ temp_patterns = [
728
+ "/tmp/",
729
+ "/temp/",
730
+ "/temporary/",
731
+ "/var/tmp/",
732
+ "/var/temp/",
733
+ "/private/var/folders/", # macOS temp
734
+ "appdata/local/temp/", # Windows temp
735
+ ] + custom_patterns
736
+
737
+ for pattern in temp_patterns:
738
+ if pattern in path_str:
739
+ return True
740
+
741
+ # Check for common temp directory names in path parts
742
+ temp_names = {"tmp", "temp", "temporary"}
743
+ for part in path.parts:
744
+ if part.lower() in temp_names:
745
+ return True
746
+
747
+ return False
748
+
749
+ def _get_custom_temp_patterns(self) -> List[str]:
750
+ """Get custom temporary directory patterns from configuration.
751
+
752
+ Returns:
753
+ List[str]: Custom temporary directory patterns
754
+ """
755
+ try:
756
+ # Try to get custom patterns from config
757
+ if hasattr(self, "config") and hasattr(self.config, "custom_temp_patterns"):
758
+ return getattr(self.config, "custom_temp_patterns", [])
759
+ except (AttributeError, TypeError):
760
+ pass
761
+
762
+ # Return empty list if no custom patterns configured
763
+ return []
764
+
765
+ def _validate_resolved_path(self, original_path: Path, resolved_path: Path) -> None:
766
+ """Enhanced validation after path resolution with security logging.
767
+
768
+ Args:
769
+ original_path: The original path before resolution
770
+ resolved_path: The resolved absolute path
771
+
772
+ Raises:
773
+ ValidationError: If path validation fails
774
+ """
775
+ # Check for path traversal attempts
776
+ if ".." in str(original_path):
777
+ logger.warning(f"Path traversal attempt detected: {original_path}")
778
+
779
+ # Verify that after resolution, we haven't escaped intended boundaries
780
+ original_parts = str(original_path).split(os.sep)
781
+ if ".." in original_parts:
782
+ # Count directory traversals
783
+ traversal_count = original_parts.count("..")
784
+ if traversal_count > 2: # Allow some reasonable traversal
785
+ logger.error(
786
+ f"Excessive path traversal blocked: {original_path} (count: {traversal_count})"
787
+ )
788
+ raise ValidationError(
789
+ f"Excessive path traversal detected in: {original_path}. "
790
+ "This may indicate a path traversal attack."
791
+ )
792
+
793
+ # Ensure resolved path doesn't point to sensitive system locations
794
+ if not self._is_path_within_safe_bounds(resolved_path):
795
+ logger.error(f"Restricted path access blocked: {resolved_path}")
796
+ raise ValidationError(
797
+ f"Resolved path points to restricted location: {resolved_path}. "
798
+ "File creation in this location is not allowed."
799
+ )
800
+
801
+ def _sanitize_template_variables(
802
+ self, template_vars: Dict[str, Any], depth: int = 0
803
+ ) -> Dict[str, Any]:
804
+ """Enhanced sanitization of template variables with security logging and recursion protection.
805
+
806
+ Args:
807
+ template_vars: Dictionary of template variables to sanitize
808
+ depth: Current recursion depth (for DoS protection)
809
+
810
+ Returns:
811
+ Dict[str, Any]: Sanitized template variables
812
+
813
+ Raises:
814
+ ValidationError: If recursion depth exceeds safe limits
815
+ """
816
+ # Prevent deep recursion DoS attacks
817
+ if depth > 10:
818
+ raise ValidationError(
819
+ f"Template variable nesting too deep (max depth: 10, current: {depth})"
820
+ )
821
+ sanitized_vars = {}
822
+ suspicious_patterns_found = []
823
+
824
+ for key, value in template_vars.items():
825
+ if isinstance(value, str):
826
+ original_value = value
827
+ sanitized_vars[key] = self._sanitize_string_value(value)
828
+ # Check if sanitization changed the value (potential injection attempt)
829
+ if original_value != sanitized_vars[key]:
830
+ suspicious_patterns_found.append(
831
+ f"Variable '{key}': {original_value[:50]}..."
832
+ )
833
+ elif isinstance(value, (int, float, bool)) or value is None:
834
+ sanitized_vars[key] = value
835
+ elif isinstance(value, (list, tuple)):
836
+ # Recursively sanitize list/tuple items
837
+ sanitized_list = []
838
+ for item in value:
839
+ if isinstance(item, dict):
840
+ sanitized_list.append(
841
+ self._sanitize_template_variables(item, depth + 1)
842
+ )
843
+ else:
844
+ sanitized_list.append(self._sanitize_single_value(item))
845
+ sanitized_vars[key] = sanitized_list
846
+ elif isinstance(value, dict):
847
+ # Recursively sanitize dict values
848
+ sanitized_vars[key] = self._sanitize_template_variables(
849
+ value, depth + 1
850
+ )
851
+ else:
852
+ # Convert other types to string and sanitize
853
+ sanitized_vars[key] = self._sanitize_single_value(str(value))
854
+
855
+ # Log security events if suspicious patterns were found
856
+ if suspicious_patterns_found:
857
+ logger.warning(
858
+ f"Potential injection patterns sanitized in template variables: {suspicious_patterns_found}"
859
+ )
860
+
861
+ return sanitized_vars
862
+
863
+ def _sanitize_string_value(self, value: str) -> str:
864
+ """Enhanced sanitization for string values to prevent various injection attacks."""
865
+ if not isinstance(value, str):
866
+ value = str(value)
867
+
868
+ # URL decode to catch encoded injection attempts
869
+ import urllib.parse
870
+
871
+ try:
872
+ decoded_value = urllib.parse.unquote(value)
873
+ except Exception:
874
+ decoded_value = value
875
+
876
+ # Unicode normalization to prevent Unicode-based attacks
877
+ import unicodedata
878
+
879
+ normalized_value = unicodedata.normalize("NFKC", decoded_value)
880
+
881
+ # Remove dangerous patterns for template injection
882
+ dangerous_patterns = [
883
+ r"\{\{.*?\}\}", # Jinja2 expressions
884
+ r"\{%.*?%\}", # Jinja2 statements
885
+ r"\{#.*?#\}", # Jinja2 comments
886
+ r"<script[^>]*>.*?</script>", # Script tags
887
+ r"javascript:", # JavaScript URLs
888
+ r"data:", # Data URLs
889
+ r"vbscript:", # VBScript URLs
890
+ r"on\w+\s*=", # Event handlers
891
+ r"\\x[0-9a-fA-F]{2}", # Hex escapes
892
+ r"\\u[0-9a-fA-F]{4}", # Unicode escapes
893
+ r"\\[rnt]", # Common escapes
894
+ r"__.*__", # Python dunder methods
895
+ r"\beval\b", # eval function
896
+ r"\bexec\b", # exec function
897
+ r"\bimport\b", # import statements
898
+ r"\bopen\b", # file operations
899
+ r"\bfile\b", # file operations
900
+ r"\bos\.", # os module access
901
+ r"\bsys\.", # sys module access
902
+ ]
903
+
904
+ sanitized = normalized_value
905
+ for pattern in dangerous_patterns:
906
+ sanitized = re.sub(pattern, "", sanitized, flags=re.IGNORECASE | re.DOTALL)
907
+
908
+ # Remove control characters and dangerous Unicode categories
909
+ sanitized = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", sanitized)
910
+
911
+ # Remove potentially dangerous characters
912
+ sanitized = re.sub(r'[<>"\'\\\/`$]', "", sanitized)
913
+
914
+ # Limit length to prevent DoS
915
+ if len(sanitized) > 1000:
916
+ sanitized = sanitized[:1000]
917
+
918
+ return sanitized
919
+
920
+ def _sanitize_single_value(self, value: Any) -> str:
921
+ """Sanitize a single value."""
922
+ if not isinstance(value, str):
923
+ value = str(value)
924
+ return self._sanitize_string_value(value)
925
+
926
+ def _sanitize_template_output(self, value: Any) -> str:
927
+ """Finalize function to sanitize template output."""
928
+ if value is None:
929
+ return ""
930
+ return self._sanitize_single_value(str(value))
931
+
932
+ def _setup_sandbox_security(self) -> None:
933
+ """Configure enhanced sandbox security settings with minimal attack surface."""
934
+ # Define a minimal set of safe builtins (reduced from previous version)
935
+ allowed_builtins = {
936
+ "range": range,
937
+ "len": len,
938
+ "str": str,
939
+ "int": int,
940
+ "bool": bool,
941
+ "list": list,
942
+ "dict": dict,
943
+ "enumerate": enumerate,
944
+ }
945
+
946
+ # Override the globals to only include safe functions
947
+ self.template_env.globals.clear()
948
+ self.template_env.globals.update(allowed_builtins)
949
+
950
+ # Define allowed filters (keep only safe ones)
951
+ safe_filters = {
952
+ "upper",
953
+ "lower",
954
+ "title",
955
+ "capitalize",
956
+ "strip",
957
+ "replace",
958
+ "length",
959
+ "first",
960
+ "last",
961
+ "join",
962
+ "default",
963
+ "trim",
964
+ "truncate",
965
+ "wordwrap",
966
+ "center",
967
+ "indent",
968
+ }
969
+
970
+ # Remove ALL potentially dangerous filters and globals
971
+ dangerous_items = [
972
+ # Attribute access
973
+ "attr",
974
+ "getattr",
975
+ "setattr",
976
+ "delattr",
977
+ "hasattr",
978
+ # Code execution
979
+ "import",
980
+ "exec",
981
+ "eval",
982
+ "compile",
983
+ "__import__",
984
+ # File operations
985
+ "open",
986
+ "file",
987
+ "input",
988
+ "raw_input",
989
+ # System access
990
+ "globals",
991
+ "locals",
992
+ "vars",
993
+ "dir",
994
+ # Object introspection
995
+ "type",
996
+ "isinstance",
997
+ "issubclass",
998
+ "callable",
999
+ # Dangerous builtins removed from allowed list
1000
+ "float",
1001
+ "tuple",
1002
+ "zip",
1003
+ "map",
1004
+ "filter",
1005
+ "reduce",
1006
+ ]
1007
+
1008
+ # Remove from filters
1009
+ for item_name in dangerous_items:
1010
+ if item_name in self.template_env.filters:
1011
+ del self.template_env.filters[item_name]
1012
+ # Also remove from globals if present
1013
+ if item_name in self.template_env.globals:
1014
+ del self.template_env.globals[item_name]
1015
+
1016
+ # Remove potentially dangerous filters
1017
+ current_filters = set(self.template_env.filters.keys())
1018
+ dangerous_filters = current_filters - safe_filters
1019
+
1020
+ for filter_name in dangerous_filters:
1021
+ if filter_name in self.template_env.filters:
1022
+ del self.template_env.filters[filter_name]
1023
+
1024
+ def _render_template(
1025
+ self, template_name: str, template_vars: Dict[str, Any]
1026
+ ) -> str:
1027
+ """Render a template with the given variables."""
1028
+ try:
1029
+ # Sanitize template variables before rendering
1030
+ sanitized_vars = self._sanitize_template_variables(template_vars)
1031
+
1032
+ template = self.template_env.get_template(template_name)
1033
+ return template.render(**sanitized_vars)
1034
+ except jinja2.TemplateNotFound:
1035
+ raise TemplateError(f"Template not found: {template_name}")
1036
+ except jinja2.TemplateError as e:
1037
+ raise TemplateError(f"Error rendering template {template_name}: {e}")
1038
+ except Exception as e:
1039
+ raise TemplateError(f"Unexpected error rendering template: {e}")