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.
- fast_clean_architecture/__init__.py +24 -0
- fast_clean_architecture/cli.py +480 -0
- fast_clean_architecture/config.py +506 -0
- fast_clean_architecture/exceptions.py +63 -0
- fast_clean_architecture/generators/__init__.py +11 -0
- fast_clean_architecture/generators/component_generator.py +1039 -0
- fast_clean_architecture/generators/config_updater.py +308 -0
- fast_clean_architecture/generators/package_generator.py +174 -0
- fast_clean_architecture/generators/template_validator.py +546 -0
- fast_clean_architecture/generators/validation_config.py +75 -0
- fast_clean_architecture/generators/validation_metrics.py +193 -0
- fast_clean_architecture/templates/__init__.py +7 -0
- fast_clean_architecture/templates/__init__.py.j2 +26 -0
- fast_clean_architecture/templates/api.py.j2 +65 -0
- fast_clean_architecture/templates/command.py.j2 +26 -0
- fast_clean_architecture/templates/entity.py.j2 +49 -0
- fast_clean_architecture/templates/external.py.j2 +61 -0
- fast_clean_architecture/templates/infrastructure_repository.py.j2 +69 -0
- fast_clean_architecture/templates/model.py.j2 +38 -0
- fast_clean_architecture/templates/query.py.j2 +26 -0
- fast_clean_architecture/templates/repository.py.j2 +57 -0
- fast_clean_architecture/templates/schemas.py.j2 +32 -0
- fast_clean_architecture/templates/service.py.j2 +109 -0
- fast_clean_architecture/templates/value_object.py.j2 +34 -0
- fast_clean_architecture/utils.py +553 -0
- fast_clean_architecture-1.0.0.dist-info/METADATA +541 -0
- fast_clean_architecture-1.0.0.dist-info/RECORD +30 -0
- fast_clean_architecture-1.0.0.dist-info/WHEEL +4 -0
- fast_clean_architecture-1.0.0.dist-info/entry_points.txt +2 -0
- 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}")
|