ostruct-cli 0.2.0__py3-none-any.whl → 0.4.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/cli/__init__.py +2 -2
- ostruct/cli/cli.py +466 -604
- ostruct/cli/click_options.py +257 -0
- ostruct/cli/errors.py +234 -183
- ostruct/cli/file_info.py +154 -50
- ostruct/cli/file_list.py +189 -64
- ostruct/cli/file_utils.py +95 -67
- ostruct/cli/path_utils.py +58 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +47 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +184 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +353 -0
- ostruct/cli/security/symlink_resolver.py +483 -0
- ostruct/cli/security/types.py +108 -0
- ostruct/cli/security/windows_paths.py +404 -0
- ostruct/cli/template_filters.py +8 -5
- ostruct/cli/template_io.py +4 -2
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/METADATA +9 -6
- ostruct_cli-0.4.0.dist-info/RECORD +36 -0
- ostruct/cli/security.py +0 -323
- ostruct/cli/security_types.py +0 -49
- ostruct_cli-0.2.0.dist-info/RECORD +0 -27
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/file_utils.py
CHANGED
@@ -58,7 +58,7 @@ from .errors import (
|
|
58
58
|
from .file_info import FileInfo
|
59
59
|
from .file_list import FileInfoList
|
60
60
|
from .security import SecurityManager
|
61
|
-
from .
|
61
|
+
from .security.types import SecurityManagerProtocol
|
62
62
|
|
63
63
|
__all__ = [
|
64
64
|
"FileInfo", # Re-exported from file_info
|
@@ -153,21 +153,21 @@ def collect_files_from_directory(
|
|
153
153
|
allowed_extensions: Optional[List[str]] = None,
|
154
154
|
**kwargs: Any,
|
155
155
|
) -> List[FileInfo]:
|
156
|
-
"""Collect files from directory.
|
156
|
+
"""Collect files from a directory.
|
157
157
|
|
158
158
|
Args:
|
159
159
|
directory: Directory to collect files from
|
160
160
|
security_manager: Security manager for path validation
|
161
|
-
recursive: Whether to
|
162
|
-
allowed_extensions: List of allowed file extensions without
|
161
|
+
recursive: Whether to process subdirectories
|
162
|
+
allowed_extensions: List of allowed file extensions (without dot)
|
163
163
|
**kwargs: Additional arguments passed to FileInfo.from_path
|
164
164
|
|
165
165
|
Returns:
|
166
|
-
List of FileInfo
|
166
|
+
List of FileInfo objects
|
167
167
|
|
168
168
|
Raises:
|
169
169
|
DirectoryNotFoundError: If directory does not exist
|
170
|
-
PathSecurityError: If directory is not allowed
|
170
|
+
PathSecurityError: If directory or any file path is not allowed
|
171
171
|
"""
|
172
172
|
logger.debug(
|
173
173
|
"Collecting files from directory: %s (recursive=%s, extensions=%s)",
|
@@ -176,86 +176,113 @@ def collect_files_from_directory(
|
|
176
176
|
allowed_extensions,
|
177
177
|
)
|
178
178
|
|
179
|
-
#
|
179
|
+
# First validate and resolve the directory path
|
180
180
|
try:
|
181
181
|
abs_dir = str(security_manager.resolve_path(directory))
|
182
|
-
logger.debug("Resolved
|
183
|
-
logger.debug(
|
184
|
-
"Security manager base_dir: %s", security_manager.base_dir
|
185
|
-
)
|
186
|
-
logger.debug(
|
187
|
-
"Security manager allowed_dirs: %s", security_manager.allowed_dirs
|
188
|
-
)
|
182
|
+
logger.debug("Resolved directory path: %s", abs_dir)
|
189
183
|
except PathSecurityError as e:
|
190
|
-
logger.
|
191
|
-
|
184
|
+
logger.error(
|
185
|
+
"Security violation in directory path: %s (%s)", directory, str(e)
|
186
|
+
)
|
192
187
|
raise
|
193
188
|
|
194
|
-
if not os.path.exists(abs_dir):
|
195
|
-
logger.debug("Directory not found: %s (abs: %s)", directory, abs_dir)
|
196
|
-
raise DirectoryNotFoundError(f"Directory not found: {directory}")
|
197
189
|
if not os.path.isdir(abs_dir):
|
198
|
-
logger.
|
199
|
-
"Path is not a directory: %s (abs: %s)", directory, abs_dir
|
200
|
-
)
|
190
|
+
logger.error("Path is not a directory: %s", abs_dir)
|
201
191
|
raise DirectoryNotFoundError(f"Path is not a directory: {directory}")
|
202
192
|
|
203
|
-
# Collect files
|
204
193
|
files: List[FileInfo] = []
|
205
|
-
for root, dirs, filenames in os.walk(abs_dir):
|
206
|
-
logger.debug("Walking directory: %s", root)
|
207
|
-
logger.debug("Found subdirectories: %s", dirs)
|
208
|
-
logger.debug("Found files: %s", filenames)
|
209
194
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
)
|
214
|
-
|
195
|
+
try:
|
196
|
+
for root, dirs, filenames in os.walk(abs_dir):
|
197
|
+
logger.debug("Walking directory: %s", root)
|
198
|
+
logger.debug("Found subdirectories: %s", dirs)
|
199
|
+
logger.debug("Found files: %s", filenames)
|
215
200
|
|
216
|
-
|
217
|
-
logger.debug("Current files collected: %d", len(files))
|
218
|
-
for filename in filenames:
|
219
|
-
# Get relative path from base directory
|
220
|
-
abs_path = os.path.join(root, filename)
|
201
|
+
# Validate current directory
|
221
202
|
try:
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
203
|
+
security_manager.validate_path(root)
|
204
|
+
except PathSecurityError as e:
|
205
|
+
logger.error(
|
206
|
+
"Security violation in subdirectory: %s (%s)", root, str(e)
|
207
|
+
)
|
208
|
+
raise
|
209
|
+
|
210
|
+
if not recursive and root != abs_dir:
|
226
211
|
logger.debug(
|
227
|
-
"Skipping
|
228
|
-
abs_path,
|
229
|
-
str(e),
|
212
|
+
"Skipping subdirectory (non-recursive mode): %s", root
|
230
213
|
)
|
231
214
|
continue
|
232
215
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
216
|
+
logger.debug("Scanning directory: %s", root)
|
217
|
+
logger.debug("Current files collected: %d", len(files))
|
218
|
+
|
219
|
+
for filename in filenames:
|
220
|
+
# Get relative path from base directory
|
221
|
+
abs_path = os.path.join(root, filename)
|
222
|
+
try:
|
223
|
+
rel_path = os.path.relpath(
|
224
|
+
abs_path, security_manager.base_dir
|
225
|
+
)
|
237
226
|
logger.debug(
|
238
|
-
"
|
239
|
-
|
240
|
-
|
241
|
-
|
227
|
+
"Processing file: %s -> %s", abs_path, rel_path
|
228
|
+
)
|
229
|
+
except ValueError as e:
|
230
|
+
logger.warning(
|
231
|
+
"Skipping file that can't be made relative: %s (error: %s)",
|
232
|
+
abs_path,
|
233
|
+
str(e),
|
242
234
|
)
|
243
235
|
continue
|
244
236
|
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
237
|
+
# Check extension if filter is specified
|
238
|
+
if allowed_extensions is not None:
|
239
|
+
ext = os.path.splitext(filename)[1].lstrip(".")
|
240
|
+
if ext not in allowed_extensions:
|
241
|
+
logger.debug(
|
242
|
+
"Skipping file with disallowed extension: %s",
|
243
|
+
filename,
|
244
|
+
)
|
245
|
+
continue
|
246
|
+
|
247
|
+
# Validate file path before creating FileInfo
|
248
|
+
try:
|
249
|
+
security_manager.validate_path(abs_path)
|
250
|
+
except PathSecurityError as e:
|
251
|
+
logger.error(
|
252
|
+
"Security violation for file: %s (%s)",
|
253
|
+
abs_path,
|
254
|
+
str(e),
|
255
|
+
)
|
256
|
+
raise
|
257
|
+
|
258
|
+
try:
|
259
|
+
file_info = FileInfo.from_path(
|
260
|
+
rel_path, security_manager=security_manager, **kwargs
|
261
|
+
)
|
262
|
+
files.append(file_info)
|
263
|
+
logger.debug("Added file to list: %s", rel_path)
|
264
|
+
except PathSecurityError as e:
|
265
|
+
# Log and re-raise security errors immediately
|
266
|
+
logger.error(
|
267
|
+
"Security violation processing file: %s (%s)",
|
268
|
+
rel_path,
|
269
|
+
str(e),
|
270
|
+
)
|
271
|
+
raise
|
272
|
+
except (FileNotFoundError, PermissionError) as e:
|
273
|
+
# Skip legitimate file access errors
|
274
|
+
logger.warning(
|
275
|
+
"Skipping inaccessible file: %s (error: %s)",
|
276
|
+
rel_path,
|
277
|
+
str(e),
|
278
|
+
)
|
279
|
+
|
280
|
+
except PathSecurityError:
|
281
|
+
# Re-raise security errors without wrapping
|
282
|
+
raise
|
283
|
+
except Exception as e:
|
284
|
+
logger.error("Error collecting files: %s", str(e))
|
285
|
+
raise
|
259
286
|
|
260
287
|
logger.debug("Collected %d files from directory %s", len(files), directory)
|
261
288
|
return files
|
@@ -415,7 +442,8 @@ def collect_files(
|
|
415
442
|
logger.debug("Security error in directory mapping: %s", str(e))
|
416
443
|
raise PathSecurityError(
|
417
444
|
"Directory mapping error: Access denied: "
|
418
|
-
f"{directory} is outside base directory and not in allowed directories"
|
445
|
+
f"{directory} is outside base directory and not in allowed directories",
|
446
|
+
path=directory,
|
419
447
|
) from e
|
420
448
|
except DirectoryNotFoundError as e:
|
421
449
|
logger.debug("Directory not found: %s", str(e))
|
ostruct/cli/path_utils.py
CHANGED
@@ -1,17 +1,16 @@
|
|
1
1
|
"""Path validation utilities for the CLI."""
|
2
2
|
|
3
|
-
import os
|
4
3
|
from pathlib import Path
|
5
4
|
from typing import Optional, Tuple
|
6
5
|
|
7
|
-
from .errors import (
|
6
|
+
from ostruct.cli.errors import (
|
8
7
|
DirectoryNotFoundError,
|
9
8
|
FileNotFoundError,
|
10
|
-
PathSecurityError,
|
11
9
|
VariableNameError,
|
12
10
|
VariableValueError,
|
13
11
|
)
|
14
|
-
from .security import
|
12
|
+
from ostruct.cli.security.errors import PathSecurityError, SecurityErrorReasons
|
13
|
+
from ostruct.cli.security.security_manager import SecurityManager
|
15
14
|
|
16
15
|
|
17
16
|
def validate_path_mapping(
|
@@ -45,79 +44,61 @@ def validate_path_mapping(
|
|
45
44
|
>>> validate_path_mapping("data=config/", is_dir=True) # Validates directory
|
46
45
|
('data', 'config/')
|
47
46
|
"""
|
47
|
+
# Split into name and path parts
|
48
48
|
try:
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
49
|
+
name, path_str = mapping.split("=", 1)
|
50
|
+
except ValueError:
|
51
|
+
raise ValueError(f"Invalid mapping format (missing '='): {mapping}")
|
52
|
+
|
53
|
+
# Validate name
|
54
|
+
name = name.strip()
|
55
|
+
if not name:
|
56
|
+
raise VariableNameError("Variable name cannot be empty")
|
57
|
+
if not name.isidentifier():
|
58
|
+
raise VariableNameError(f"Invalid variable name: {name}")
|
59
|
+
|
60
|
+
# Normalize path
|
61
|
+
path_str = path_str.strip()
|
62
|
+
if not path_str:
|
63
|
+
raise VariableValueError("Path cannot be empty")
|
64
|
+
|
65
|
+
# Create a Path object
|
66
|
+
path = Path(path_str)
|
67
|
+
if not path.is_absolute() and base_dir:
|
68
|
+
path = Path(base_dir) / path
|
69
|
+
|
70
|
+
# Validate path with security manager if provided
|
71
|
+
if security_manager:
|
89
72
|
try:
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
if e.errno == 13: # Permission denied
|
73
|
+
path = security_manager.validate_path(path)
|
74
|
+
except PathSecurityError as e:
|
75
|
+
if (
|
76
|
+
e.context.get("reason")
|
77
|
+
== SecurityErrorReasons.PATH_OUTSIDE_ALLOWED
|
78
|
+
):
|
97
79
|
raise PathSecurityError(
|
98
|
-
f"
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
raise
|
80
|
+
f"Path '{path}' is outside the base directory and not in allowed directories",
|
81
|
+
path=str(path),
|
82
|
+
context=e.context,
|
83
|
+
) from e
|
84
|
+
raise PathSecurityError(
|
85
|
+
f"Path validation failed: {e}",
|
86
|
+
path=str(path),
|
87
|
+
context=e.context,
|
88
|
+
) from e
|
89
|
+
|
90
|
+
# Check path existence and type
|
91
|
+
if not path.exists():
|
92
|
+
if is_dir:
|
93
|
+
raise DirectoryNotFoundError(f"Directory not found: {path}")
|
94
|
+
raise FileNotFoundError(f"File not found: {path}")
|
95
|
+
|
96
|
+
# Check path type
|
97
|
+
if is_dir and not path.is_dir():
|
98
|
+
raise DirectoryNotFoundError(
|
99
|
+
f"Path exists but is not a directory: {path}"
|
100
|
+
)
|
101
|
+
elif not is_dir and not path.is_file():
|
102
|
+
raise FileNotFoundError(f"Path exists but is not a file: {path}")
|
103
|
+
|
104
|
+
return name, str(path)
|
@@ -0,0 +1,32 @@
|
|
1
|
+
"""Security package for file access management.
|
2
|
+
|
3
|
+
This package provides a comprehensive set of security features for file access:
|
4
|
+
- Path normalization and validation
|
5
|
+
- Safe path joining
|
6
|
+
- Directory traversal prevention
|
7
|
+
- Symlink resolution with security checks
|
8
|
+
- Case sensitivity handling
|
9
|
+
- Temporary path management
|
10
|
+
"""
|
11
|
+
|
12
|
+
from .allowed_checker import is_path_in_allowed_dirs
|
13
|
+
from .case_manager import CaseManager
|
14
|
+
from .errors import (
|
15
|
+
DirectoryNotFoundError,
|
16
|
+
PathSecurityError,
|
17
|
+
SecurityErrorReasons,
|
18
|
+
)
|
19
|
+
from .normalization import normalize_path
|
20
|
+
from .safe_joiner import safe_join
|
21
|
+
from .security_manager import SecurityManager
|
22
|
+
|
23
|
+
__all__ = [
|
24
|
+
"normalize_path",
|
25
|
+
"safe_join",
|
26
|
+
"is_path_in_allowed_dirs",
|
27
|
+
"CaseManager",
|
28
|
+
"PathSecurityError",
|
29
|
+
"DirectoryNotFoundError",
|
30
|
+
"SecurityErrorReasons",
|
31
|
+
"SecurityManager",
|
32
|
+
]
|
@@ -0,0 +1,47 @@
|
|
1
|
+
"""Allowed directory checker module.
|
2
|
+
|
3
|
+
This module provides functionality to verify that a given path is within
|
4
|
+
one of a set of allowed directories.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import List, Union
|
9
|
+
|
10
|
+
from .normalization import normalize_path
|
11
|
+
|
12
|
+
|
13
|
+
def is_path_in_allowed_dirs(
|
14
|
+
path: Union[str, Path], allowed_dirs: List[Path]
|
15
|
+
) -> bool:
|
16
|
+
"""Check if a given path is inside any of the allowed directories.
|
17
|
+
|
18
|
+
This function normalizes both the input path and allowed directories
|
19
|
+
before comparison to ensure consistent results across platforms.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
path: The path to check.
|
23
|
+
allowed_dirs: A list of allowed directory paths.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
True if path is within one of the allowed directories; False otherwise.
|
27
|
+
|
28
|
+
Example:
|
29
|
+
>>> allowed = [Path("/base"), Path("/tmp")]
|
30
|
+
>>> is_path_in_allowed_dirs("/base/file.txt", allowed)
|
31
|
+
True
|
32
|
+
>>> is_path_in_allowed_dirs("/etc/passwd", allowed)
|
33
|
+
False
|
34
|
+
"""
|
35
|
+
norm_path = normalize_path(path)
|
36
|
+
norm_allowed = [normalize_path(d) for d in allowed_dirs]
|
37
|
+
|
38
|
+
for allowed in norm_allowed:
|
39
|
+
try:
|
40
|
+
# If path.relative_to(allowed) does not raise an error,
|
41
|
+
# then path is within allowed.
|
42
|
+
norm_path.relative_to(allowed)
|
43
|
+
return True
|
44
|
+
except ValueError:
|
45
|
+
continue
|
46
|
+
|
47
|
+
return False
|
@@ -0,0 +1,75 @@
|
|
1
|
+
"""Case management module.
|
2
|
+
|
3
|
+
This module provides a class for tracking and preserving the original case
|
4
|
+
of file paths on case-insensitive systems.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from pathlib import Path
|
8
|
+
from threading import Lock
|
9
|
+
from typing import Dict
|
10
|
+
|
11
|
+
|
12
|
+
class CaseManager:
|
13
|
+
"""Manages original case preservation for paths.
|
14
|
+
|
15
|
+
This class provides a thread-safe way to track original case preservation
|
16
|
+
without modifying Path objects. This is particularly important on
|
17
|
+
case-insensitive systems (macOS, Windows) where we normalize paths
|
18
|
+
to lowercase but want to preserve the original case for display.
|
19
|
+
|
20
|
+
Example:
|
21
|
+
>>> CaseManager.set_original_case(Path("/tmp/file.txt"), "/TMP/File.txt")
|
22
|
+
>>> CaseManager.get_original_case(Path("/tmp/file.txt"))
|
23
|
+
'/TMP/File.txt'
|
24
|
+
"""
|
25
|
+
|
26
|
+
_case_mapping: Dict[str, str] = {}
|
27
|
+
_lock = Lock()
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
def set_original_case(
|
31
|
+
cls, normalized_path: Path, original_case: str
|
32
|
+
) -> None:
|
33
|
+
"""Store the original case for a normalized path.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
normalized_path: The normalized (potentially lowercased) Path.
|
37
|
+
original_case: The original path string with its original case.
|
38
|
+
|
39
|
+
Raises:
|
40
|
+
TypeError: If normalized_path or original_case is None.
|
41
|
+
"""
|
42
|
+
if normalized_path is None:
|
43
|
+
raise TypeError("normalized_path cannot be None")
|
44
|
+
if original_case is None:
|
45
|
+
raise TypeError("original_case cannot be None")
|
46
|
+
|
47
|
+
with cls._lock:
|
48
|
+
cls._case_mapping[str(normalized_path)] = original_case
|
49
|
+
|
50
|
+
@classmethod
|
51
|
+
def get_original_case(cls, normalized_path: Path) -> str:
|
52
|
+
"""Retrieve the original case for a normalized path.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
normalized_path: The normalized Path.
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
The original case string if stored; otherwise the normalized path string.
|
59
|
+
|
60
|
+
Raises:
|
61
|
+
TypeError: If normalized_path is None.
|
62
|
+
"""
|
63
|
+
if normalized_path is None:
|
64
|
+
raise TypeError("normalized_path cannot be None")
|
65
|
+
|
66
|
+
with cls._lock:
|
67
|
+
return cls._case_mapping.get(
|
68
|
+
str(normalized_path), str(normalized_path)
|
69
|
+
)
|
70
|
+
|
71
|
+
@classmethod
|
72
|
+
def clear(cls) -> None:
|
73
|
+
"""Clear all stored case mappings."""
|
74
|
+
with cls._lock:
|
75
|
+
cls._case_mapping.clear()
|