ostruct-cli 0.3.0__py3-none-any.whl → 0.5.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/base_errors.py +183 -0
- ostruct/cli/cli.py +830 -585
- ostruct/cli/click_options.py +338 -211
- ostruct/cli/errors.py +214 -227
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +126 -69
- ostruct/cli/file_list.py +191 -72
- ostruct/cli/file_utils.py +132 -97
- ostruct/cli/path_utils.py +86 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +55 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +164 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +366 -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/serialization.py +25 -0
- ostruct/cli/template_filters.py +13 -8
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +64 -24
- ostruct_cli-0.5.0.dist-info/RECORD +42 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
- ostruct/cli/security.py +0 -964
- ostruct/cli/security_types.py +0 -46
- ostruct_cli-0.3.0.dist-info/RECORD +0 -28
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/file_utils.py
CHANGED
@@ -46,19 +46,20 @@ import codecs
|
|
46
46
|
import glob
|
47
47
|
import logging
|
48
48
|
import os
|
49
|
-
from
|
49
|
+
from pathlib import Path
|
50
|
+
from typing import Any, Dict, List, Optional, Tuple, Type, Union
|
50
51
|
|
51
52
|
import chardet
|
52
53
|
|
53
54
|
from .errors import (
|
54
55
|
DirectoryNotFoundError,
|
55
|
-
|
56
|
+
OstructFileNotFoundError,
|
56
57
|
PathSecurityError,
|
57
58
|
)
|
58
59
|
from .file_info import FileInfo
|
59
60
|
from .file_list import FileInfoList
|
60
61
|
from .security import SecurityManager
|
61
|
-
from .
|
62
|
+
from .security.types import SecurityManagerProtocol
|
62
63
|
|
63
64
|
__all__ = [
|
64
65
|
"FileInfo", # Re-exported from file_info
|
@@ -113,10 +114,10 @@ def collect_files_from_pattern(
|
|
113
114
|
pattern: str,
|
114
115
|
security_manager: SecurityManager,
|
115
116
|
) -> List[FileInfo]:
|
116
|
-
"""Collect files matching a glob pattern.
|
117
|
+
"""Collect files matching a glob pattern or exact file path.
|
117
118
|
|
118
119
|
Args:
|
119
|
-
pattern: Glob pattern to match
|
120
|
+
pattern: Glob pattern or file path to match
|
120
121
|
security_manager: Security manager for path validation
|
121
122
|
|
122
123
|
Returns:
|
@@ -125,7 +126,18 @@ def collect_files_from_pattern(
|
|
125
126
|
Raises:
|
126
127
|
PathSecurityError: If any matched file is outside base directory
|
127
128
|
"""
|
128
|
-
#
|
129
|
+
# First check if it's an exact file path
|
130
|
+
if os.path.isfile(pattern):
|
131
|
+
try:
|
132
|
+
file_info = FileInfo.from_path(pattern, security_manager)
|
133
|
+
return [file_info]
|
134
|
+
except PathSecurityError:
|
135
|
+
raise
|
136
|
+
except Exception as e:
|
137
|
+
logger.warning("Could not process file %s: %s", pattern, str(e))
|
138
|
+
return []
|
139
|
+
|
140
|
+
# If not an exact file, try glob pattern
|
129
141
|
matched_paths = glob.glob(pattern, recursive=True)
|
130
142
|
if not matched_paths:
|
131
143
|
logger.debug("No files matched pattern: %s", pattern)
|
@@ -140,8 +152,8 @@ def collect_files_from_pattern(
|
|
140
152
|
except PathSecurityError:
|
141
153
|
# Let security errors propagate
|
142
154
|
raise
|
143
|
-
except Exception:
|
144
|
-
logger.warning("Could not process file %s", path)
|
155
|
+
except Exception as e:
|
156
|
+
logger.warning("Could not process file %s: %s", path, str(e))
|
145
157
|
|
146
158
|
return files
|
147
159
|
|
@@ -153,21 +165,21 @@ def collect_files_from_directory(
|
|
153
165
|
allowed_extensions: Optional[List[str]] = None,
|
154
166
|
**kwargs: Any,
|
155
167
|
) -> List[FileInfo]:
|
156
|
-
"""Collect files from directory.
|
168
|
+
"""Collect files from a directory.
|
157
169
|
|
158
170
|
Args:
|
159
171
|
directory: Directory to collect files from
|
160
172
|
security_manager: Security manager for path validation
|
161
|
-
recursive: Whether to
|
162
|
-
allowed_extensions: List of allowed file extensions without
|
173
|
+
recursive: Whether to process subdirectories
|
174
|
+
allowed_extensions: List of allowed file extensions (without dot)
|
163
175
|
**kwargs: Additional arguments passed to FileInfo.from_path
|
164
176
|
|
165
177
|
Returns:
|
166
|
-
List of FileInfo
|
178
|
+
List of FileInfo objects
|
167
179
|
|
168
180
|
Raises:
|
169
181
|
DirectoryNotFoundError: If directory does not exist
|
170
|
-
PathSecurityError: If directory is not allowed
|
182
|
+
PathSecurityError: If directory or any file path is not allowed
|
171
183
|
"""
|
172
184
|
logger.debug(
|
173
185
|
"Collecting files from directory: %s (recursive=%s, extensions=%s)",
|
@@ -176,125 +188,148 @@ def collect_files_from_directory(
|
|
176
188
|
allowed_extensions,
|
177
189
|
)
|
178
190
|
|
179
|
-
#
|
191
|
+
# First validate and resolve the directory path
|
180
192
|
try:
|
181
193
|
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
|
-
)
|
194
|
+
logger.debug("Resolved directory path: %s", abs_dir)
|
189
195
|
except PathSecurityError as e:
|
190
|
-
logger.
|
191
|
-
|
196
|
+
logger.error(
|
197
|
+
"Security violation in directory path: %s (%s)", directory, str(e)
|
198
|
+
)
|
192
199
|
raise
|
193
200
|
|
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
201
|
if not os.path.isdir(abs_dir):
|
198
|
-
logger.
|
199
|
-
"Path is not a directory: %s (abs: %s)", directory, abs_dir
|
200
|
-
)
|
202
|
+
logger.error("Path is not a directory: %s", abs_dir)
|
201
203
|
raise DirectoryNotFoundError(f"Path is not a directory: {directory}")
|
202
204
|
|
203
|
-
# Collect files
|
204
205
|
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
206
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
)
|
214
|
-
|
207
|
+
try:
|
208
|
+
for root, dirs, filenames in os.walk(abs_dir):
|
209
|
+
logger.debug("Walking directory: %s", root)
|
210
|
+
logger.debug("Found subdirectories: %s", dirs)
|
211
|
+
logger.debug("Found files: %s", filenames)
|
215
212
|
|
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)
|
213
|
+
# Validate current directory
|
221
214
|
try:
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
215
|
+
security_manager.validate_path(root)
|
216
|
+
except PathSecurityError as e:
|
217
|
+
logger.error(
|
218
|
+
"Security violation in subdirectory: %s (%s)", root, str(e)
|
219
|
+
)
|
220
|
+
raise
|
221
|
+
|
222
|
+
if not recursive and root != abs_dir:
|
226
223
|
logger.debug(
|
227
|
-
"Skipping
|
228
|
-
abs_path,
|
229
|
-
str(e),
|
224
|
+
"Skipping subdirectory (non-recursive mode): %s", root
|
230
225
|
)
|
231
226
|
continue
|
232
227
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
228
|
+
logger.debug("Scanning directory: %s", root)
|
229
|
+
logger.debug("Current files collected: %d", len(files))
|
230
|
+
|
231
|
+
for filename in filenames:
|
232
|
+
# Get relative path from base directory
|
233
|
+
abs_path = os.path.join(root, filename)
|
234
|
+
try:
|
235
|
+
rel_path = os.path.relpath(
|
236
|
+
abs_path, security_manager.base_dir
|
237
|
+
)
|
237
238
|
logger.debug(
|
238
|
-
"
|
239
|
-
|
240
|
-
|
241
|
-
|
239
|
+
"Processing file: %s -> %s", abs_path, rel_path
|
240
|
+
)
|
241
|
+
except ValueError as e:
|
242
|
+
logger.warning(
|
243
|
+
"Skipping file that can't be made relative: %s (error: %s)",
|
244
|
+
abs_path,
|
245
|
+
str(e),
|
242
246
|
)
|
243
247
|
continue
|
244
248
|
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
249
|
+
# Check extension if filter is specified
|
250
|
+
if allowed_extensions is not None:
|
251
|
+
ext = os.path.splitext(filename)[1].lstrip(".")
|
252
|
+
if ext not in allowed_extensions:
|
253
|
+
logger.debug(
|
254
|
+
"Skipping file with disallowed extension: %s",
|
255
|
+
filename,
|
256
|
+
)
|
257
|
+
continue
|
258
|
+
|
259
|
+
# Validate file path before creating FileInfo
|
260
|
+
try:
|
261
|
+
security_manager.validate_path(abs_path)
|
262
|
+
except PathSecurityError as e:
|
263
|
+
logger.error(
|
264
|
+
"Security violation for file: %s (%s)",
|
265
|
+
abs_path,
|
266
|
+
str(e),
|
267
|
+
)
|
268
|
+
raise
|
269
|
+
|
270
|
+
try:
|
271
|
+
# Use absolute path when creating FileInfo
|
272
|
+
file_info = FileInfo.from_path(
|
273
|
+
abs_path, security_manager=security_manager, **kwargs
|
274
|
+
)
|
275
|
+
files.append(file_info)
|
276
|
+
logger.debug("Added file to list: %s", abs_path)
|
277
|
+
except PathSecurityError as e:
|
278
|
+
# Log and re-raise security errors immediately
|
279
|
+
logger.error(
|
280
|
+
"Security violation processing file: %s (%s)",
|
281
|
+
abs_path,
|
282
|
+
str(e),
|
283
|
+
)
|
284
|
+
raise
|
285
|
+
except (OstructFileNotFoundError, PermissionError) as e:
|
286
|
+
# Skip legitimate file access errors
|
287
|
+
logger.warning(
|
288
|
+
"Skipping inaccessible file: %s (error: %s)",
|
289
|
+
rel_path,
|
290
|
+
str(e),
|
291
|
+
)
|
292
|
+
|
293
|
+
except PathSecurityError:
|
294
|
+
# Re-raise security errors without wrapping
|
295
|
+
raise
|
296
|
+
except Exception as e:
|
297
|
+
logger.error("Error collecting files: %s", str(e))
|
298
|
+
raise
|
259
299
|
|
260
300
|
logger.debug("Collected %d files from directory %s", len(files), directory)
|
261
301
|
return files
|
262
302
|
|
263
303
|
|
264
304
|
def _validate_and_split_mapping(
|
265
|
-
mapping: str, mapping_type: str
|
305
|
+
mapping: tuple[str, Union[str, Path]], mapping_type: str
|
266
306
|
) -> tuple[str, str]:
|
267
|
-
"""Validate
|
307
|
+
"""Validate a name/path tuple mapping.
|
268
308
|
|
269
309
|
Args:
|
270
|
-
mapping: The mapping
|
310
|
+
mapping: The mapping tuple (name, path)
|
271
311
|
mapping_type: Type of mapping for error messages ("file", "pattern", or "directory")
|
272
312
|
|
273
313
|
Returns:
|
274
|
-
|
314
|
+
The same tuple of (name, path)
|
275
315
|
|
276
316
|
Raises:
|
277
317
|
ValueError: If mapping format is invalid
|
278
318
|
"""
|
279
|
-
|
280
|
-
name, value = mapping.split("=", 1)
|
281
|
-
except ValueError:
|
282
|
-
raise ValueError(
|
283
|
-
f"Invalid {mapping_type} mapping format: {mapping!r} (missing '=' separator)"
|
284
|
-
)
|
319
|
+
name, value = mapping
|
285
320
|
|
286
321
|
if not name:
|
287
|
-
raise ValueError(f"Empty name in {mapping_type} mapping
|
322
|
+
raise ValueError(f"Empty name in {mapping_type} mapping")
|
288
323
|
if not value:
|
289
|
-
raise ValueError(f"Empty value in {mapping_type} mapping
|
324
|
+
raise ValueError(f"Empty value in {mapping_type} mapping")
|
290
325
|
|
291
|
-
return name, value
|
326
|
+
return name, str(value) # Convert Path to str if needed
|
292
327
|
|
293
328
|
|
294
329
|
def collect_files(
|
295
|
-
file_mappings: Optional[List[str]] = None,
|
296
|
-
pattern_mappings: Optional[List[str]] = None,
|
297
|
-
dir_mappings: Optional[List[str]] = None,
|
330
|
+
file_mappings: Optional[List[Tuple[str, Union[str, Path]]]] = None,
|
331
|
+
pattern_mappings: Optional[List[Tuple[str, Union[str, Path]]]] = None,
|
332
|
+
dir_mappings: Optional[List[Tuple[str, Union[str, Path]]]] = None,
|
298
333
|
dir_recursive: bool = False,
|
299
334
|
dir_extensions: Optional[List[str]] = None,
|
300
335
|
security_manager: Optional[SecurityManager] = None,
|
@@ -303,9 +338,9 @@ def collect_files(
|
|
303
338
|
"""Collect files from multiple sources.
|
304
339
|
|
305
340
|
Args:
|
306
|
-
file_mappings: List of file mappings
|
307
|
-
pattern_mappings: List of pattern mappings
|
308
|
-
dir_mappings: List of directory mappings
|
341
|
+
file_mappings: List of file mappings as (name, path) tuples
|
342
|
+
pattern_mappings: List of pattern mappings as (name, pattern) tuples
|
343
|
+
dir_mappings: List of directory mappings as (name, directory) tuples
|
309
344
|
dir_recursive: Whether to process directories recursively
|
310
345
|
dir_extensions: List of file extensions to include in directory processing
|
311
346
|
security_manager: Security manager instance
|
@@ -356,7 +391,7 @@ def collect_files(
|
|
356
391
|
raise ValueError(f"Duplicate file mapping: {name}")
|
357
392
|
|
358
393
|
file_info = FileInfo.from_path(
|
359
|
-
path, security_manager=security_manager, **kwargs
|
394
|
+
str(path), security_manager=security_manager, **kwargs
|
360
395
|
)
|
361
396
|
files[name] = FileInfoList([file_info], from_dir=False)
|
362
397
|
logger.debug("Added single file mapping: %s -> %s", name, path)
|
@@ -371,7 +406,7 @@ def collect_files(
|
|
371
406
|
|
372
407
|
try:
|
373
408
|
matched_files = collect_files_from_pattern(
|
374
|
-
pattern, security_manager=security_manager, **kwargs
|
409
|
+
str(pattern), security_manager=security_manager, **kwargs
|
375
410
|
)
|
376
411
|
except PathSecurityError as e:
|
377
412
|
logger.debug("Security error in pattern mapping: %s", str(e))
|
@@ -438,7 +473,7 @@ def collect_files(
|
|
438
473
|
|
439
474
|
if not files:
|
440
475
|
logger.debug("No files found in any mappings")
|
441
|
-
|
476
|
+
return files
|
442
477
|
|
443
478
|
logger.debug("Collected files total mappings: %d", len(files))
|
444
479
|
return files
|
@@ -582,14 +617,14 @@ def read_allowed_dirs_from_file(filepath: str) -> List[str]:
|
|
582
617
|
A list of allowed directories as absolute paths.
|
583
618
|
|
584
619
|
Raises:
|
585
|
-
|
620
|
+
OstructFileNotFoundError: If the file does not exist.
|
586
621
|
ValueError: If the file contains invalid data.
|
587
622
|
"""
|
588
623
|
try:
|
589
624
|
with open(filepath, "r") as f:
|
590
625
|
lines = f.readlines()
|
591
626
|
except OSError as e:
|
592
|
-
raise
|
627
|
+
raise OstructFileNotFoundError(
|
593
628
|
f"Error reading allowed directories from file: {filepath}: {e}"
|
594
629
|
)
|
595
630
|
|
ostruct/cli/path_utils.py
CHANGED
@@ -1,17 +1,20 @@
|
|
1
1
|
"""Path validation utilities for the CLI."""
|
2
2
|
|
3
|
-
import
|
3
|
+
import logging
|
4
4
|
from pathlib import Path
|
5
5
|
from typing import Optional, Tuple
|
6
6
|
|
7
|
-
from .errors import (
|
7
|
+
from ostruct.cli.errors import (
|
8
8
|
DirectoryNotFoundError,
|
9
|
-
|
9
|
+
OstructFileNotFoundError,
|
10
10
|
PathSecurityError,
|
11
11
|
VariableNameError,
|
12
12
|
VariableValueError,
|
13
13
|
)
|
14
|
-
from .security import
|
14
|
+
from ostruct.cli.security.errors import SecurityErrorReasons
|
15
|
+
from ostruct.cli.security.security_manager import SecurityManager
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
15
18
|
|
16
19
|
|
17
20
|
def validate_path_mapping(
|
@@ -45,79 +48,85 @@ def validate_path_mapping(
|
|
45
48
|
>>> validate_path_mapping("data=config/", is_dir=True) # Validates directory
|
46
49
|
('data', 'config/')
|
47
50
|
"""
|
51
|
+
logger.debug(
|
52
|
+
"Validating path mapping: %s (is_dir=%s, base_dir=%s)",
|
53
|
+
mapping,
|
54
|
+
is_dir,
|
55
|
+
base_dir,
|
56
|
+
)
|
57
|
+
|
58
|
+
# Split into name and path parts
|
48
59
|
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
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
60
|
+
name, path_str = mapping.split("=", 1)
|
61
|
+
except ValueError:
|
62
|
+
logger.error("Invalid mapping format (missing '='): %s", mapping)
|
63
|
+
raise ValueError(f"Invalid mapping format (missing '='): {mapping}")
|
64
|
+
|
65
|
+
# Validate name
|
66
|
+
name = name.strip()
|
67
|
+
if not name:
|
68
|
+
logger.error("Variable name cannot be empty: %s", mapping)
|
69
|
+
raise VariableNameError("Variable name cannot be empty")
|
70
|
+
if not name.isidentifier():
|
71
|
+
logger.error("Invalid variable name: %s", name)
|
72
|
+
raise VariableNameError(f"Invalid variable name: {name}")
|
73
|
+
|
74
|
+
# Normalize path
|
75
|
+
path_str = path_str.strip()
|
76
|
+
if not path_str:
|
77
|
+
logger.error("Path cannot be empty: %s", mapping)
|
78
|
+
raise VariableValueError("Path cannot be empty")
|
79
|
+
|
80
|
+
logger.debug("Creating Path object for: %s", path_str)
|
81
|
+
# Create a Path object
|
82
|
+
path = Path(path_str)
|
83
|
+
if not path.is_absolute() and base_dir:
|
84
|
+
logger.debug(
|
85
|
+
"Converting relative path to absolute using base_dir: %s", base_dir
|
86
|
+
)
|
87
|
+
path = Path(base_dir) / path
|
88
|
+
|
89
|
+
# Validate path with security manager if provided
|
90
|
+
if security_manager:
|
91
|
+
logger.debug("Validating path with security manager: %s", path)
|
89
92
|
try:
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
93
|
+
path = security_manager.validate_path(path)
|
94
|
+
logger.debug("Security validation passed: %s", path)
|
95
|
+
except PathSecurityError as e:
|
96
|
+
logger.error("Security validation failed: %s - %s", path, e)
|
97
|
+
if (
|
98
|
+
e.context.get("reason")
|
99
|
+
== SecurityErrorReasons.PATH_OUTSIDE_ALLOWED
|
100
|
+
):
|
97
101
|
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
|
102
|
+
f"Path '{path}' is outside the base directory and not in allowed directories",
|
103
|
+
path=str(path),
|
104
|
+
context=e.context,
|
105
|
+
) from e
|
106
|
+
raise PathSecurityError(
|
107
|
+
f"Path validation failed: {e}",
|
108
|
+
path=str(path),
|
109
|
+
context=e.context,
|
110
|
+
) from e
|
111
|
+
|
112
|
+
# Check path existence and type
|
113
|
+
if not path.exists():
|
114
|
+
logger.error("Path does not exist: %s", path)
|
115
|
+
if is_dir:
|
116
|
+
raise DirectoryNotFoundError(f"Directory not found: {path}")
|
117
|
+
raise OstructFileNotFoundError(f"File not found: {path}")
|
118
|
+
|
119
|
+
# Check path type
|
120
|
+
if is_dir and not path.is_dir():
|
121
|
+
logger.error("Path exists but is not a directory: %s", path)
|
122
|
+
raise DirectoryNotFoundError(
|
123
|
+
f"Path exists but is not a directory: {path}"
|
124
|
+
)
|
125
|
+
elif not is_dir and not path.is_file():
|
126
|
+
logger.error("Path exists but is not a file: %s", path)
|
127
|
+
raise OstructFileNotFoundError(
|
128
|
+
f"Path exists but is not a file: {path}"
|
129
|
+
)
|
130
|
+
|
131
|
+
logger.debug("Path validation successful: %s -> %s", name, path)
|
132
|
+
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,55 @@
|
|
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
|
+
Raises:
|
29
|
+
TypeError: If path is None or not a string/Path object.
|
30
|
+
|
31
|
+
Example:
|
32
|
+
>>> allowed = [Path("/base"), Path("/tmp")]
|
33
|
+
>>> is_path_in_allowed_dirs("/base/file.txt", allowed)
|
34
|
+
True
|
35
|
+
>>> is_path_in_allowed_dirs("/etc/passwd", allowed)
|
36
|
+
False
|
37
|
+
"""
|
38
|
+
if path is None:
|
39
|
+
raise TypeError("path must be a string or Path object")
|
40
|
+
if not isinstance(path, (str, Path)):
|
41
|
+
raise TypeError("path must be a string or Path object")
|
42
|
+
|
43
|
+
norm_path = normalize_path(path)
|
44
|
+
norm_allowed = [normalize_path(d) for d in allowed_dirs]
|
45
|
+
|
46
|
+
for allowed in norm_allowed:
|
47
|
+
try:
|
48
|
+
# If path.relative_to(allowed) does not raise an error,
|
49
|
+
# then path is within allowed.
|
50
|
+
norm_path.relative_to(allowed)
|
51
|
+
return True
|
52
|
+
except ValueError:
|
53
|
+
continue
|
54
|
+
|
55
|
+
return False
|