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.
Files changed (35) hide show
  1. ostruct/cli/base_errors.py +183 -0
  2. ostruct/cli/cli.py +830 -585
  3. ostruct/cli/click_options.py +338 -211
  4. ostruct/cli/errors.py +214 -227
  5. ostruct/cli/exit_codes.py +18 -0
  6. ostruct/cli/file_info.py +126 -69
  7. ostruct/cli/file_list.py +191 -72
  8. ostruct/cli/file_utils.py +132 -97
  9. ostruct/cli/path_utils.py +86 -77
  10. ostruct/cli/security/__init__.py +32 -0
  11. ostruct/cli/security/allowed_checker.py +55 -0
  12. ostruct/cli/security/base.py +46 -0
  13. ostruct/cli/security/case_manager.py +75 -0
  14. ostruct/cli/security/errors.py +164 -0
  15. ostruct/cli/security/normalization.py +161 -0
  16. ostruct/cli/security/safe_joiner.py +211 -0
  17. ostruct/cli/security/security_manager.py +366 -0
  18. ostruct/cli/security/symlink_resolver.py +483 -0
  19. ostruct/cli/security/types.py +108 -0
  20. ostruct/cli/security/windows_paths.py +404 -0
  21. ostruct/cli/serialization.py +25 -0
  22. ostruct/cli/template_filters.py +13 -8
  23. ostruct/cli/template_rendering.py +46 -22
  24. ostruct/cli/template_utils.py +12 -4
  25. ostruct/cli/template_validation.py +26 -8
  26. ostruct/cli/token_utils.py +43 -0
  27. ostruct/cli/validators.py +109 -0
  28. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +64 -24
  29. ostruct_cli-0.5.0.dist-info/RECORD +42 -0
  30. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
  31. ostruct/cli/security.py +0 -964
  32. ostruct/cli/security_types.py +0 -46
  33. ostruct_cli-0.3.0.dist-info/RECORD +0 -28
  34. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
  35. {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 typing import Any, Dict, List, Optional, Type, Union
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
- FileNotFoundError,
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 .security_types import SecurityManagerProtocol
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 files
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
- # Expand pattern
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 collect files recursively
162
- allowed_extensions: List of allowed file extensions without dots
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 instances
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
- # Validate directory exists and is allowed
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 absolute directory path: %s", abs_dir)
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.debug("PathSecurityError while resolving directory: %s", str(e))
191
- # Let the original error propagate
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.debug(
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
- if not recursive and root != abs_dir:
211
- logger.debug(
212
- "Skipping subdirectory (non-recursive mode): %s", root
213
- )
214
- continue
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
- logger.debug("Scanning directory: %s", root)
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
- rel_path = os.path.relpath(abs_path, security_manager.base_dir)
223
- logger.debug("Processing file: %s -> %s", abs_path, rel_path)
224
- except ValueError as e:
225
- # Skip files that can't be made relative
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 file that can't be made relative: %s (error: %s)",
228
- abs_path,
229
- str(e),
224
+ "Skipping subdirectory (non-recursive mode): %s", root
230
225
  )
231
226
  continue
232
227
 
233
- # Check extension if filter is specified
234
- if allowed_extensions is not None:
235
- ext = os.path.splitext(filename)[1].lstrip(".")
236
- if ext not in allowed_extensions:
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
- "Skipping file with non-matching extension: %s (ext=%s, allowed=%s)",
239
- filename,
240
- ext,
241
- allowed_extensions,
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
- try:
246
- file_info = FileInfo.from_path(
247
- rel_path, security_manager=security_manager, **kwargs
248
- )
249
- files.append(file_info)
250
- logger.debug("Added file to list: %s", rel_path)
251
- except (FileNotFoundError, PathSecurityError) as e:
252
- # Skip files that can't be accessed
253
- logger.debug(
254
- "Skipping inaccessible file: %s (error: %s)",
255
- rel_path,
256
- str(e),
257
- )
258
- continue
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 and split a name=value mapping.
307
+ """Validate a name/path tuple mapping.
268
308
 
269
309
  Args:
270
- mapping: The mapping string to validate (e.g. "name=value")
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
- Tuple of (name, value)
314
+ The same tuple of (name, path)
275
315
 
276
316
  Raises:
277
317
  ValueError: If mapping format is invalid
278
318
  """
279
- try:
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: {mapping!r}")
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: {mapping!r}")
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 in the format "name=path"
307
- pattern_mappings: List of pattern mappings in the format "name=pattern"
308
- dir_mappings: List of directory mappings in the format "name=directory"
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
- raise ValueError("No files found")
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
- FileNotFoundError: If the file does not exist.
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 FileNotFoundError(
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 os
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
- FileNotFoundError,
9
+ OstructFileNotFoundError,
10
10
  PathSecurityError,
11
11
  VariableNameError,
12
12
  VariableValueError,
13
13
  )
14
- from .security import SecurityManager
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
- 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
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
- 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
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"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
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