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.
@@ -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
@@ -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
@@ -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)