ostruct-cli 0.1.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.
- ostruct/__init__.py +0 -0
- ostruct/cli/__init__.py +19 -0
- ostruct/cli/cache_manager.py +175 -0
- ostruct/cli/cli.py +2033 -0
- ostruct/cli/errors.py +329 -0
- ostruct/cli/file_info.py +316 -0
- ostruct/cli/file_list.py +151 -0
- ostruct/cli/file_utils.py +518 -0
- ostruct/cli/path_utils.py +123 -0
- ostruct/cli/progress.py +105 -0
- ostruct/cli/security.py +311 -0
- ostruct/cli/security_types.py +49 -0
- ostruct/cli/template_env.py +55 -0
- ostruct/cli/template_extensions.py +51 -0
- ostruct/cli/template_filters.py +650 -0
- ostruct/cli/template_io.py +261 -0
- ostruct/cli/template_rendering.py +347 -0
- ostruct/cli/template_schema.py +565 -0
- ostruct/cli/template_utils.py +288 -0
- ostruct/cli/template_validation.py +375 -0
- ostruct/cli/utils.py +31 -0
- ostruct/py.typed +0 -0
- ostruct_cli-0.1.0.dist-info/LICENSE +21 -0
- ostruct_cli-0.1.0.dist-info/METADATA +182 -0
- ostruct_cli-0.1.0.dist-info/RECORD +27 -0
- ostruct_cli-0.1.0.dist-info/WHEEL +4 -0
- ostruct_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
"""Path validation utilities for the CLI."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Tuple
|
6
|
+
|
7
|
+
from .errors import (
|
8
|
+
DirectoryNotFoundError,
|
9
|
+
FileNotFoundError,
|
10
|
+
PathSecurityError,
|
11
|
+
VariableNameError,
|
12
|
+
VariableValueError,
|
13
|
+
)
|
14
|
+
from .security import SecurityManager
|
15
|
+
|
16
|
+
|
17
|
+
def validate_path_mapping(
|
18
|
+
mapping: str,
|
19
|
+
is_dir: bool = False,
|
20
|
+
base_dir: Optional[str] = None,
|
21
|
+
security_manager: Optional[SecurityManager] = None,
|
22
|
+
) -> Tuple[str, str]:
|
23
|
+
"""Validate a path mapping in the format "name=path".
|
24
|
+
|
25
|
+
Args:
|
26
|
+
mapping: The path mapping string (e.g., "myvar=/path/to/file").
|
27
|
+
is_dir: Whether the path is expected to be a directory (True) or file (False).
|
28
|
+
base_dir: Optional base directory to resolve relative paths against.
|
29
|
+
security_manager: Optional security manager to validate paths.
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
A (name, path) tuple.
|
33
|
+
|
34
|
+
Raises:
|
35
|
+
VariableNameError: If the variable name portion is empty or invalid.
|
36
|
+
DirectoryNotFoundError: If is_dir=True and the path is not a directory or doesn't exist.
|
37
|
+
FileNotFoundError: If is_dir=False and the path is not a file or doesn't exist.
|
38
|
+
PathSecurityError: If the path is inaccessible or outside the allowed directory.
|
39
|
+
ValueError: If the format is invalid (missing "=").
|
40
|
+
OSError: If there is an underlying OS error (permissions, etc.).
|
41
|
+
|
42
|
+
Example:
|
43
|
+
>>> validate_path_mapping("config=settings.txt") # Validates file
|
44
|
+
('config', 'settings.txt')
|
45
|
+
>>> validate_path_mapping("data=config/", is_dir=True) # Validates directory
|
46
|
+
('data', 'config/')
|
47
|
+
"""
|
48
|
+
try:
|
49
|
+
if not mapping or "=" not in mapping:
|
50
|
+
raise ValueError("Invalid mapping format")
|
51
|
+
|
52
|
+
name, path = mapping.split("=", 1)
|
53
|
+
if not name:
|
54
|
+
raise VariableNameError(
|
55
|
+
f"Empty name in {'directory' if is_dir else 'file'} mapping"
|
56
|
+
)
|
57
|
+
|
58
|
+
if not path:
|
59
|
+
raise VariableValueError("Path cannot be empty")
|
60
|
+
|
61
|
+
# Expand user home directory and environment variables
|
62
|
+
path = os.path.expanduser(os.path.expandvars(path))
|
63
|
+
|
64
|
+
# Convert to Path object and resolve against base_dir if provided
|
65
|
+
path_obj = Path(path)
|
66
|
+
if base_dir:
|
67
|
+
path_obj = Path(base_dir) / path_obj
|
68
|
+
|
69
|
+
# Resolve the path to catch directory traversal attempts
|
70
|
+
try:
|
71
|
+
resolved_path = path_obj.resolve()
|
72
|
+
except OSError as e:
|
73
|
+
raise OSError(f"Failed to resolve path: {e}")
|
74
|
+
|
75
|
+
# Check if path exists
|
76
|
+
if not resolved_path.exists():
|
77
|
+
if is_dir:
|
78
|
+
raise DirectoryNotFoundError(f"Directory not found: {path!r}")
|
79
|
+
else:
|
80
|
+
raise FileNotFoundError(f"File not found: {path!r}")
|
81
|
+
|
82
|
+
# Check if path is correct type
|
83
|
+
if is_dir and not resolved_path.is_dir():
|
84
|
+
raise DirectoryNotFoundError(f"Path is not a directory: {path!r}")
|
85
|
+
elif not is_dir and not resolved_path.is_file():
|
86
|
+
raise FileNotFoundError(f"Path is not a file: {path!r}")
|
87
|
+
|
88
|
+
# Check if path is accessible
|
89
|
+
try:
|
90
|
+
if is_dir:
|
91
|
+
os.listdir(str(resolved_path))
|
92
|
+
else:
|
93
|
+
with open(str(resolved_path), "r", encoding="utf-8") as f:
|
94
|
+
f.read(1)
|
95
|
+
except OSError as e:
|
96
|
+
if e.errno == 13: # Permission denied
|
97
|
+
raise PathSecurityError(
|
98
|
+
f"Permission denied accessing path: {path!r}"
|
99
|
+
)
|
100
|
+
raise
|
101
|
+
|
102
|
+
# Check security constraints
|
103
|
+
if security_manager:
|
104
|
+
if not security_manager.is_path_allowed(str(resolved_path)):
|
105
|
+
raise PathSecurityError.from_expanded_paths(
|
106
|
+
original_path=str(path),
|
107
|
+
expanded_path=str(resolved_path),
|
108
|
+
base_dir=str(security_manager.base_dir),
|
109
|
+
allowed_dirs=[
|
110
|
+
str(d) for d in security_manager.allowed_dirs
|
111
|
+
],
|
112
|
+
)
|
113
|
+
|
114
|
+
# Return the original path to maintain relative paths in the output
|
115
|
+
return name, path
|
116
|
+
|
117
|
+
except ValueError as e:
|
118
|
+
if "not enough values to unpack" in str(e):
|
119
|
+
raise VariableValueError(
|
120
|
+
f"Invalid {'directory' if is_dir else 'file'} mapping "
|
121
|
+
f"(expected name=path format): {mapping!r}"
|
122
|
+
)
|
123
|
+
raise
|
ostruct/cli/progress.py
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
"""Progress reporting for CLI operations."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from typing import Any, Optional, Type
|
5
|
+
|
6
|
+
logger = logging.getLogger(__name__)
|
7
|
+
|
8
|
+
|
9
|
+
class ProgressContext:
|
10
|
+
"""Simple context manager for output handling.
|
11
|
+
|
12
|
+
This is a minimal implementation that just handles direct output to stdout.
|
13
|
+
No progress reporting is implemented - it simply prints output directly.
|
14
|
+
"""
|
15
|
+
|
16
|
+
def __init__(
|
17
|
+
self,
|
18
|
+
description: str = "Processing",
|
19
|
+
total: Optional[int] = None,
|
20
|
+
level: str = "basic",
|
21
|
+
output_file: Optional[str] = None,
|
22
|
+
):
|
23
|
+
logger.debug(
|
24
|
+
"Initializing ProgressContext with level=%s, output_file=%s",
|
25
|
+
level,
|
26
|
+
output_file,
|
27
|
+
)
|
28
|
+
logger.debug("Description: %s, total: %s", description, total)
|
29
|
+
self._output_file = output_file
|
30
|
+
self._level = level
|
31
|
+
self.enabled = level != "none"
|
32
|
+
self.current: int = 0
|
33
|
+
logger.debug(
|
34
|
+
"ProgressContext initialized with enabled=%s", self.enabled
|
35
|
+
)
|
36
|
+
|
37
|
+
def __enter__(self) -> "ProgressContext":
|
38
|
+
logger.debug("Entering ProgressContext with level=%s", self._level)
|
39
|
+
return self
|
40
|
+
|
41
|
+
def __exit__(
|
42
|
+
self,
|
43
|
+
exc_type: Optional[Type[BaseException]],
|
44
|
+
exc_val: Optional[BaseException],
|
45
|
+
exc_tb: Optional[Any],
|
46
|
+
) -> None:
|
47
|
+
logger.debug(
|
48
|
+
"Exiting ProgressContext. Had exception: %s", exc_type is not None
|
49
|
+
)
|
50
|
+
if exc_type:
|
51
|
+
logger.error(
|
52
|
+
"Exception in ProgressContext: %s - %s", exc_type, exc_val
|
53
|
+
)
|
54
|
+
pass
|
55
|
+
|
56
|
+
def update(self, amount: int = 1, force: bool = False) -> None:
|
57
|
+
"""No-op update method kept for compatibility."""
|
58
|
+
logger.debug(
|
59
|
+
"Update called with amount=%d, force=%s, current=%d",
|
60
|
+
amount,
|
61
|
+
force,
|
62
|
+
self.current,
|
63
|
+
)
|
64
|
+
self.current += amount
|
65
|
+
pass
|
66
|
+
|
67
|
+
def print_output(self, text: str) -> None:
|
68
|
+
"""Print output to stdout or file.
|
69
|
+
|
70
|
+
Args:
|
71
|
+
text: Text to print
|
72
|
+
"""
|
73
|
+
logger.debug("print_output called with text length=%d", len(text))
|
74
|
+
logger.debug("First 100 chars of text: %s", text[:100] if text else "")
|
75
|
+
logger.debug(
|
76
|
+
"Output file: %s, enabled=%s, level=%s",
|
77
|
+
self._output_file,
|
78
|
+
self.enabled,
|
79
|
+
self._level,
|
80
|
+
)
|
81
|
+
|
82
|
+
try:
|
83
|
+
if self._output_file:
|
84
|
+
logger.debug("Writing to output file: %s", self._output_file)
|
85
|
+
with open(self._output_file, "a", encoding="utf-8") as f:
|
86
|
+
logger.debug("File opened successfully, writing content")
|
87
|
+
f.write(text)
|
88
|
+
f.write("\n")
|
89
|
+
logger.debug(
|
90
|
+
"Successfully wrote %d chars to file", len(text)
|
91
|
+
)
|
92
|
+
else:
|
93
|
+
logger.debug("Writing to stdout")
|
94
|
+
print(text)
|
95
|
+
logger.debug(
|
96
|
+
"Successfully wrote %d chars to stdout", len(text)
|
97
|
+
)
|
98
|
+
except Exception as e:
|
99
|
+
logger.error("Failed to write output: %s", e)
|
100
|
+
raise
|
101
|
+
|
102
|
+
def step(self, description: str) -> "ProgressContext":
|
103
|
+
"""No-op step method kept for compatibility."""
|
104
|
+
logger.debug("Step called with description: %s", description)
|
105
|
+
return self
|
ostruct/cli/security.py
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
"""Security management for file access.
|
2
|
+
|
3
|
+
This module provides security checks for file access, including:
|
4
|
+
- Base directory restrictions
|
5
|
+
- Allowed directory validation
|
6
|
+
- Path traversal prevention
|
7
|
+
- Temporary directory handling
|
8
|
+
"""
|
9
|
+
|
10
|
+
import logging
|
11
|
+
import os
|
12
|
+
import tempfile
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import List, Optional, Set
|
15
|
+
|
16
|
+
from .errors import DirectoryNotFoundError, PathSecurityError
|
17
|
+
from .security_types import SecurityManagerProtocol
|
18
|
+
|
19
|
+
|
20
|
+
def is_temp_file(path: str) -> bool:
|
21
|
+
"""Check if a file is in a temporary directory.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
path: Path to check (will be converted to absolute path)
|
25
|
+
|
26
|
+
Returns:
|
27
|
+
True if the path is in a temporary directory, False otherwise
|
28
|
+
|
29
|
+
Note:
|
30
|
+
This function handles platform-specific path normalization, including symlinks
|
31
|
+
(e.g., on macOS where /var is symlinked to /private/var).
|
32
|
+
"""
|
33
|
+
# Normalize the input path (resolve symlinks)
|
34
|
+
abs_path = os.path.realpath(path)
|
35
|
+
|
36
|
+
# Get all potential temp directories and normalize them
|
37
|
+
temp_dirs = set()
|
38
|
+
# System temp dir (platform independent)
|
39
|
+
temp_dirs.add(os.path.realpath(tempfile.gettempdir()))
|
40
|
+
|
41
|
+
# Common Unix/Linux/macOS temp locations
|
42
|
+
unix_temp_dirs = ["/tmp", "/var/tmp", "/var/folders"]
|
43
|
+
for temp_dir in unix_temp_dirs:
|
44
|
+
if os.path.exists(temp_dir):
|
45
|
+
temp_dirs.add(os.path.realpath(temp_dir))
|
46
|
+
|
47
|
+
# Windows temp locations (if on Windows)
|
48
|
+
if os.name == "nt":
|
49
|
+
if "TEMP" in os.environ:
|
50
|
+
temp_dirs.add(os.path.realpath(os.environ["TEMP"]))
|
51
|
+
if "TMP" in os.environ:
|
52
|
+
temp_dirs.add(os.path.realpath(os.environ["TMP"]))
|
53
|
+
|
54
|
+
# Check if file is in any temp directory using normalized paths
|
55
|
+
abs_path_parts = os.path.normpath(abs_path).split(os.sep)
|
56
|
+
for temp_dir in temp_dirs:
|
57
|
+
temp_dir_parts = os.path.normpath(temp_dir).split(os.sep)
|
58
|
+
# Check if the path starts with the temp directory components
|
59
|
+
if len(abs_path_parts) >= len(temp_dir_parts) and all(
|
60
|
+
a == b
|
61
|
+
for a, b in zip(
|
62
|
+
abs_path_parts[: len(temp_dir_parts)], temp_dir_parts
|
63
|
+
)
|
64
|
+
):
|
65
|
+
return True
|
66
|
+
|
67
|
+
return False
|
68
|
+
|
69
|
+
|
70
|
+
class SecurityManager(SecurityManagerProtocol):
|
71
|
+
"""Manages security for file access.
|
72
|
+
|
73
|
+
Validates all file access against a base directory and optional
|
74
|
+
allowed directories. Prevents unauthorized access and directory
|
75
|
+
traversal attacks.
|
76
|
+
|
77
|
+
The security model is based on:
|
78
|
+
1. A base directory that serves as the root for all file operations
|
79
|
+
2. A set of explicitly allowed directories that can be accessed outside the base directory
|
80
|
+
3. Special handling for temporary directories that are always allowed
|
81
|
+
|
82
|
+
All paths are normalized using realpath() to handle symlinks consistently across platforms.
|
83
|
+
"""
|
84
|
+
|
85
|
+
def __init__(
|
86
|
+
self,
|
87
|
+
base_dir: Optional[str] = None,
|
88
|
+
allowed_dirs: Optional[List[str]] = None,
|
89
|
+
):
|
90
|
+
"""Initialize security manager.
|
91
|
+
|
92
|
+
Args:
|
93
|
+
base_dir: Base directory for file access. Defaults to current working directory.
|
94
|
+
allowed_dirs: Optional list of additional allowed directories
|
95
|
+
|
96
|
+
All paths are normalized using realpath to handle symlinks
|
97
|
+
and relative paths consistently across platforms.
|
98
|
+
"""
|
99
|
+
self._base_dir = Path(os.path.realpath(base_dir or os.getcwd()))
|
100
|
+
self._allowed_dirs: Set[Path] = set()
|
101
|
+
if allowed_dirs:
|
102
|
+
for directory in allowed_dirs:
|
103
|
+
self.add_allowed_dir(directory)
|
104
|
+
|
105
|
+
@property
|
106
|
+
def base_dir(self) -> Path:
|
107
|
+
"""Get the base directory."""
|
108
|
+
return self._base_dir
|
109
|
+
|
110
|
+
@property
|
111
|
+
def allowed_dirs(self) -> List[Path]:
|
112
|
+
"""Get the list of allowed directories."""
|
113
|
+
return sorted(self._allowed_dirs) # Sort for consistent ordering
|
114
|
+
|
115
|
+
def add_allowed_dir(self, directory: str) -> None:
|
116
|
+
"""Add a directory to the set of allowed directories.
|
117
|
+
|
118
|
+
Args:
|
119
|
+
directory: Directory to allow access to
|
120
|
+
|
121
|
+
Raises:
|
122
|
+
DirectoryNotFoundError: If directory does not exist
|
123
|
+
"""
|
124
|
+
real_path = Path(os.path.realpath(directory))
|
125
|
+
if not real_path.exists():
|
126
|
+
raise DirectoryNotFoundError(f"Directory not found: {directory}")
|
127
|
+
if not real_path.is_dir():
|
128
|
+
raise DirectoryNotFoundError(
|
129
|
+
f"Path is not a directory: {directory}"
|
130
|
+
)
|
131
|
+
self._allowed_dirs.add(real_path)
|
132
|
+
|
133
|
+
def add_allowed_dirs_from_file(self, file_path: str) -> None:
|
134
|
+
"""Add allowed directories from a file.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
file_path: Path to file containing allowed directories (one per line)
|
138
|
+
|
139
|
+
Raises:
|
140
|
+
PathSecurityError: If file_path is outside allowed directories
|
141
|
+
FileNotFoundError: If file does not exist
|
142
|
+
ValueError: If file contains invalid directories
|
143
|
+
"""
|
144
|
+
real_path = Path(os.path.realpath(file_path))
|
145
|
+
|
146
|
+
# First validate the file path itself
|
147
|
+
try:
|
148
|
+
self.validate_path(
|
149
|
+
str(real_path), purpose="read allowed directories"
|
150
|
+
)
|
151
|
+
except PathSecurityError:
|
152
|
+
raise PathSecurityError.from_expanded_paths(
|
153
|
+
original_path=file_path,
|
154
|
+
expanded_path=str(real_path),
|
155
|
+
error_logged=True,
|
156
|
+
base_dir=str(self._base_dir),
|
157
|
+
allowed_dirs=[str(d) for d in self._allowed_dirs],
|
158
|
+
)
|
159
|
+
|
160
|
+
if not real_path.exists():
|
161
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
162
|
+
|
163
|
+
with open(real_path) as f:
|
164
|
+
for line in f:
|
165
|
+
directory = line.strip()
|
166
|
+
if directory and not directory.startswith("#"):
|
167
|
+
self.add_allowed_dir(directory)
|
168
|
+
|
169
|
+
def is_path_allowed(self, path: str) -> bool:
|
170
|
+
"""Check if a path is allowed.
|
171
|
+
|
172
|
+
A path is allowed if it is:
|
173
|
+
1. Under the normalized base directory
|
174
|
+
2. Under any normalized allowed directory
|
175
|
+
|
176
|
+
The path must also exist.
|
177
|
+
|
178
|
+
Args:
|
179
|
+
path: Path to check
|
180
|
+
|
181
|
+
Returns:
|
182
|
+
bool: True if path exists and is allowed, False otherwise
|
183
|
+
"""
|
184
|
+
logger = logging.getLogger("ostruct")
|
185
|
+
logger.debug("Checking if path is allowed: %s", path)
|
186
|
+
logger.debug("Base directory: %s", self._base_dir)
|
187
|
+
logger.debug("Allowed directories: %s", self._allowed_dirs)
|
188
|
+
|
189
|
+
try:
|
190
|
+
real_path = Path(os.path.realpath(path))
|
191
|
+
logger.debug("Resolved real path: %s", real_path)
|
192
|
+
|
193
|
+
# Check if the path exists
|
194
|
+
if not real_path.exists():
|
195
|
+
logger.debug("Path does not exist")
|
196
|
+
return False
|
197
|
+
|
198
|
+
except (ValueError, OSError) as e:
|
199
|
+
logger.debug("Failed to resolve real path: %s", e)
|
200
|
+
return False
|
201
|
+
|
202
|
+
try:
|
203
|
+
if real_path.is_relative_to(self._base_dir):
|
204
|
+
logger.debug("Path is relative to base directory")
|
205
|
+
return True
|
206
|
+
except ValueError:
|
207
|
+
logger.debug("Path is not relative to base directory")
|
208
|
+
|
209
|
+
for allowed_dir in self._allowed_dirs:
|
210
|
+
try:
|
211
|
+
if real_path.is_relative_to(allowed_dir):
|
212
|
+
logger.debug(
|
213
|
+
"Path is relative to allowed directory: %s",
|
214
|
+
allowed_dir,
|
215
|
+
)
|
216
|
+
return True
|
217
|
+
except ValueError:
|
218
|
+
logger.debug(
|
219
|
+
"Path is not relative to allowed directory: %s",
|
220
|
+
allowed_dir,
|
221
|
+
)
|
222
|
+
continue
|
223
|
+
|
224
|
+
logger.debug("Path is not allowed")
|
225
|
+
return False
|
226
|
+
|
227
|
+
def validate_path(self, path: str, purpose: str = "access") -> Path:
|
228
|
+
"""Validate and normalize a path.
|
229
|
+
|
230
|
+
Args:
|
231
|
+
path: Path to validate
|
232
|
+
purpose: Description of intended access (for error messages)
|
233
|
+
|
234
|
+
Returns:
|
235
|
+
Path: Normalized path if valid
|
236
|
+
|
237
|
+
Raises:
|
238
|
+
PathSecurityError: If path is not allowed
|
239
|
+
"""
|
240
|
+
logger = logging.getLogger("ostruct")
|
241
|
+
logger.debug("Validating path for %s: %s", purpose, path)
|
242
|
+
|
243
|
+
try:
|
244
|
+
real_path = Path(os.path.realpath(path))
|
245
|
+
logger.debug("Resolved real path: %s", real_path)
|
246
|
+
except (ValueError, OSError) as e:
|
247
|
+
logger.error("Invalid path format: %s", e)
|
248
|
+
raise PathSecurityError(
|
249
|
+
f"Invalid path format: {e}", error_logged=True
|
250
|
+
)
|
251
|
+
|
252
|
+
if not self.is_path_allowed(str(real_path)):
|
253
|
+
logger.error(
|
254
|
+
"Access denied: %s is outside base directory and not in allowed directories",
|
255
|
+
path,
|
256
|
+
)
|
257
|
+
raise PathSecurityError.from_expanded_paths(
|
258
|
+
original_path=path,
|
259
|
+
expanded_path=str(real_path),
|
260
|
+
base_dir=str(self._base_dir),
|
261
|
+
allowed_dirs=[str(d) for d in self._allowed_dirs],
|
262
|
+
error_logged=True,
|
263
|
+
)
|
264
|
+
|
265
|
+
logger.debug("Path validation successful")
|
266
|
+
return real_path
|
267
|
+
|
268
|
+
def is_allowed_file(self, path: str) -> bool:
|
269
|
+
"""Check if file access is allowed.
|
270
|
+
|
271
|
+
Args:
|
272
|
+
path: Path to check
|
273
|
+
|
274
|
+
Returns:
|
275
|
+
bool: True if file exists and is allowed
|
276
|
+
"""
|
277
|
+
try:
|
278
|
+
real_path = Path(os.path.realpath(path))
|
279
|
+
return self.is_path_allowed(str(real_path)) and real_path.is_file()
|
280
|
+
except (ValueError, OSError):
|
281
|
+
return False
|
282
|
+
|
283
|
+
def is_allowed_path(self, path_str: str) -> bool:
|
284
|
+
"""Check if string path is allowed.
|
285
|
+
|
286
|
+
Args:
|
287
|
+
path_str: Path string to check
|
288
|
+
|
289
|
+
Returns:
|
290
|
+
bool: True if path is allowed
|
291
|
+
"""
|
292
|
+
try:
|
293
|
+
return self.is_path_allowed(path_str)
|
294
|
+
except (ValueError, OSError):
|
295
|
+
return False
|
296
|
+
|
297
|
+
def resolve_path(self, path: str) -> Path:
|
298
|
+
"""Resolve and validate a path.
|
299
|
+
|
300
|
+
This is an alias for validate_path() for backward compatibility.
|
301
|
+
|
302
|
+
Args:
|
303
|
+
path: Path to resolve and validate
|
304
|
+
|
305
|
+
Returns:
|
306
|
+
Path: Normalized path if valid
|
307
|
+
|
308
|
+
Raises:
|
309
|
+
PathSecurityError: If path is not allowed
|
310
|
+
"""
|
311
|
+
return self.validate_path(path)
|
@@ -0,0 +1,49 @@
|
|
1
|
+
"""Security type definitions and protocols."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import List, Protocol
|
5
|
+
|
6
|
+
|
7
|
+
class SecurityManagerProtocol(Protocol):
|
8
|
+
"""Protocol defining the interface for security managers."""
|
9
|
+
|
10
|
+
@property
|
11
|
+
def base_dir(self) -> Path:
|
12
|
+
"""Get the base directory."""
|
13
|
+
...
|
14
|
+
|
15
|
+
@property
|
16
|
+
def allowed_dirs(self) -> List[Path]:
|
17
|
+
"""Get the list of allowed directories."""
|
18
|
+
...
|
19
|
+
|
20
|
+
def add_allowed_dir(self, directory: str) -> None:
|
21
|
+
"""Add a directory to the set of allowed directories."""
|
22
|
+
...
|
23
|
+
|
24
|
+
def add_allowed_dirs_from_file(self, file_path: str) -> None:
|
25
|
+
"""Add allowed directories from a file."""
|
26
|
+
...
|
27
|
+
|
28
|
+
def is_path_allowed(self, path: str) -> bool:
|
29
|
+
"""Check if a path is allowed."""
|
30
|
+
...
|
31
|
+
|
32
|
+
def validate_path(self, path: str, purpose: str = "access") -> Path:
|
33
|
+
"""Validate and normalize a path."""
|
34
|
+
...
|
35
|
+
|
36
|
+
def is_allowed_file(self, path: str) -> bool:
|
37
|
+
"""Check if file access is allowed."""
|
38
|
+
...
|
39
|
+
|
40
|
+
def is_allowed_path(self, path_str: str) -> bool:
|
41
|
+
"""Check if string path is allowed."""
|
42
|
+
...
|
43
|
+
|
44
|
+
def resolve_path(self, path: str) -> Path:
|
45
|
+
"""Resolve and validate a path.
|
46
|
+
|
47
|
+
This is an alias for validate_path() for backward compatibility.
|
48
|
+
"""
|
49
|
+
...
|
@@ -0,0 +1,55 @@
|
|
1
|
+
"""Jinja2 environment factory and configuration.
|
2
|
+
|
3
|
+
This module provides a centralized factory for creating consistently configured Jinja2 environments.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from typing import Optional, Type
|
7
|
+
|
8
|
+
import jinja2
|
9
|
+
from jinja2 import Environment
|
10
|
+
|
11
|
+
from .template_extensions import CommentExtension
|
12
|
+
from .template_filters import register_template_filters
|
13
|
+
|
14
|
+
|
15
|
+
def create_jinja_env(
|
16
|
+
*,
|
17
|
+
undefined: Optional[Type[jinja2.Undefined]] = None,
|
18
|
+
loader: Optional[jinja2.BaseLoader] = None,
|
19
|
+
validation_mode: bool = False,
|
20
|
+
) -> Environment:
|
21
|
+
"""Create a consistently configured Jinja2 environment.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
undefined: Custom undefined class to use. Defaults to StrictUndefined.
|
25
|
+
loader: Template loader to use. Defaults to None.
|
26
|
+
validation_mode: Whether to configure the environment for validation (uses SafeUndefined).
|
27
|
+
|
28
|
+
Returns:
|
29
|
+
A configured Jinja2 environment.
|
30
|
+
"""
|
31
|
+
if validation_mode:
|
32
|
+
from .template_validation import SafeUndefined
|
33
|
+
|
34
|
+
undefined = SafeUndefined
|
35
|
+
elif undefined is None:
|
36
|
+
undefined = jinja2.StrictUndefined
|
37
|
+
|
38
|
+
env = Environment(
|
39
|
+
loader=loader,
|
40
|
+
undefined=undefined,
|
41
|
+
autoescape=False, # Disable HTML escaping by default
|
42
|
+
trim_blocks=True,
|
43
|
+
lstrip_blocks=True,
|
44
|
+
keep_trailing_newline=True,
|
45
|
+
extensions=[
|
46
|
+
"jinja2.ext.do",
|
47
|
+
"jinja2.ext.loopcontrols",
|
48
|
+
CommentExtension,
|
49
|
+
],
|
50
|
+
)
|
51
|
+
|
52
|
+
# Register all template filters
|
53
|
+
register_template_filters(env)
|
54
|
+
|
55
|
+
return env
|
@@ -0,0 +1,51 @@
|
|
1
|
+
"""Custom Jinja2 extensions for enhanced template functionality.
|
2
|
+
|
3
|
+
This module provides extensions that modify Jinja2's default behavior:
|
4
|
+
- CommentExtension: Ignores variables inside comment blocks during validation and rendering
|
5
|
+
"""
|
6
|
+
|
7
|
+
from jinja2 import nodes
|
8
|
+
from jinja2.ext import Extension
|
9
|
+
from jinja2.parser import Parser
|
10
|
+
|
11
|
+
|
12
|
+
class CommentExtension(Extension): # type: ignore[misc]
|
13
|
+
"""Extension that ignores variables inside comment blocks.
|
14
|
+
|
15
|
+
This extension ensures that:
|
16
|
+
1. Contents of comment blocks are completely ignored during parsing
|
17
|
+
2. Variables inside comments are not validated or processed
|
18
|
+
3. Comments are stripped from the output
|
19
|
+
"""
|
20
|
+
|
21
|
+
tags = {"comment"}
|
22
|
+
|
23
|
+
def parse(self, parser: Parser) -> nodes.Node:
|
24
|
+
"""Parse a comment block, ignoring its contents.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
parser: The Jinja2 parser instance
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
An empty node since comments are ignored
|
31
|
+
|
32
|
+
Raises:
|
33
|
+
TemplateSyntaxError: If the comment block is not properly closed
|
34
|
+
"""
|
35
|
+
# Get the line number for error reporting
|
36
|
+
lineno = parser.stream.current.lineno
|
37
|
+
|
38
|
+
# Skip the opening comment tag
|
39
|
+
next(parser.stream)
|
40
|
+
|
41
|
+
# Skip until we find {% endcomment %}
|
42
|
+
while not parser.stream.current.test("name:endcomment"):
|
43
|
+
if parser.stream.current.type == "eof":
|
44
|
+
raise parser.fail("Unclosed comment block", lineno)
|
45
|
+
next(parser.stream)
|
46
|
+
|
47
|
+
# Skip the endcomment tag
|
48
|
+
next(parser.stream)
|
49
|
+
|
50
|
+
# Return an empty string node
|
51
|
+
return nodes.Output([nodes.TemplateData("")]).set_lineno(lineno)
|