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/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 .security_types import SecurityManagerProtocol
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 collect files recursively
162
- allowed_extensions: List of allowed file extensions without dots
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 instances
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
- # Validate directory exists and is allowed
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 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
- )
182
+ logger.debug("Resolved directory path: %s", abs_dir)
189
183
  except PathSecurityError as e:
190
- logger.debug("PathSecurityError while resolving directory: %s", str(e))
191
- # Let the original error propagate
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.debug(
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
- if not recursive and root != abs_dir:
211
- logger.debug(
212
- "Skipping subdirectory (non-recursive mode): %s", root
213
- )
214
- continue
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
- 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)
201
+ # Validate current directory
221
202
  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
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 file that can't be made relative: %s (error: %s)",
228
- abs_path,
229
- str(e),
212
+ "Skipping subdirectory (non-recursive mode): %s", root
230
213
  )
231
214
  continue
232
215
 
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:
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
- "Skipping file with non-matching extension: %s (ext=%s, allowed=%s)",
239
- filename,
240
- ext,
241
- allowed_extensions,
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
- 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
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 SecurityManager
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
- 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
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
- 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
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"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
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()