ostruct-cli 0.4.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/file_utils.py CHANGED
@@ -46,13 +46,14 @@ 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
@@ -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
 
@@ -256,20 +268,21 @@ def collect_files_from_directory(
256
268
  raise
257
269
 
258
270
  try:
271
+ # Use absolute path when creating FileInfo
259
272
  file_info = FileInfo.from_path(
260
- rel_path, security_manager=security_manager, **kwargs
273
+ abs_path, security_manager=security_manager, **kwargs
261
274
  )
262
275
  files.append(file_info)
263
- logger.debug("Added file to list: %s", rel_path)
276
+ logger.debug("Added file to list: %s", abs_path)
264
277
  except PathSecurityError as e:
265
278
  # Log and re-raise security errors immediately
266
279
  logger.error(
267
280
  "Security violation processing file: %s (%s)",
268
- rel_path,
281
+ abs_path,
269
282
  str(e),
270
283
  )
271
284
  raise
272
- except (FileNotFoundError, PermissionError) as e:
285
+ except (OstructFileNotFoundError, PermissionError) as e:
273
286
  # Skip legitimate file access errors
274
287
  logger.warning(
275
288
  "Skipping inaccessible file: %s (error: %s)",
@@ -289,39 +302,34 @@ def collect_files_from_directory(
289
302
 
290
303
 
291
304
  def _validate_and_split_mapping(
292
- mapping: str, mapping_type: str
305
+ mapping: tuple[str, Union[str, Path]], mapping_type: str
293
306
  ) -> tuple[str, str]:
294
- """Validate and split a name=value mapping.
307
+ """Validate a name/path tuple mapping.
295
308
 
296
309
  Args:
297
- mapping: The mapping string to validate (e.g. "name=value")
310
+ mapping: The mapping tuple (name, path)
298
311
  mapping_type: Type of mapping for error messages ("file", "pattern", or "directory")
299
312
 
300
313
  Returns:
301
- Tuple of (name, value)
314
+ The same tuple of (name, path)
302
315
 
303
316
  Raises:
304
317
  ValueError: If mapping format is invalid
305
318
  """
306
- try:
307
- name, value = mapping.split("=", 1)
308
- except ValueError:
309
- raise ValueError(
310
- f"Invalid {mapping_type} mapping format: {mapping!r} (missing '=' separator)"
311
- )
319
+ name, value = mapping
312
320
 
313
321
  if not name:
314
- raise ValueError(f"Empty name in {mapping_type} mapping: {mapping!r}")
322
+ raise ValueError(f"Empty name in {mapping_type} mapping")
315
323
  if not value:
316
- raise ValueError(f"Empty value in {mapping_type} mapping: {mapping!r}")
324
+ raise ValueError(f"Empty value in {mapping_type} mapping")
317
325
 
318
- return name, value
326
+ return name, str(value) # Convert Path to str if needed
319
327
 
320
328
 
321
329
  def collect_files(
322
- file_mappings: Optional[List[str]] = None,
323
- pattern_mappings: Optional[List[str]] = None,
324
- 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,
325
333
  dir_recursive: bool = False,
326
334
  dir_extensions: Optional[List[str]] = None,
327
335
  security_manager: Optional[SecurityManager] = None,
@@ -330,9 +338,9 @@ def collect_files(
330
338
  """Collect files from multiple sources.
331
339
 
332
340
  Args:
333
- file_mappings: List of file mappings in the format "name=path"
334
- pattern_mappings: List of pattern mappings in the format "name=pattern"
335
- 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
336
344
  dir_recursive: Whether to process directories recursively
337
345
  dir_extensions: List of file extensions to include in directory processing
338
346
  security_manager: Security manager instance
@@ -383,7 +391,7 @@ def collect_files(
383
391
  raise ValueError(f"Duplicate file mapping: {name}")
384
392
 
385
393
  file_info = FileInfo.from_path(
386
- path, security_manager=security_manager, **kwargs
394
+ str(path), security_manager=security_manager, **kwargs
387
395
  )
388
396
  files[name] = FileInfoList([file_info], from_dir=False)
389
397
  logger.debug("Added single file mapping: %s -> %s", name, path)
@@ -398,7 +406,7 @@ def collect_files(
398
406
 
399
407
  try:
400
408
  matched_files = collect_files_from_pattern(
401
- pattern, security_manager=security_manager, **kwargs
409
+ str(pattern), security_manager=security_manager, **kwargs
402
410
  )
403
411
  except PathSecurityError as e:
404
412
  logger.debug("Security error in pattern mapping: %s", str(e))
@@ -465,7 +473,7 @@ def collect_files(
465
473
 
466
474
  if not files:
467
475
  logger.debug("No files found in any mappings")
468
- raise ValueError("No files found")
476
+ return files
469
477
 
470
478
  logger.debug("Collected files total mappings: %d", len(files))
471
479
  return files
@@ -609,14 +617,14 @@ def read_allowed_dirs_from_file(filepath: str) -> List[str]:
609
617
  A list of allowed directories as absolute paths.
610
618
 
611
619
  Raises:
612
- FileNotFoundError: If the file does not exist.
620
+ OstructFileNotFoundError: If the file does not exist.
613
621
  ValueError: If the file contains invalid data.
614
622
  """
615
623
  try:
616
624
  with open(filepath, "r") as f:
617
625
  lines = f.readlines()
618
626
  except OSError as e:
619
- raise FileNotFoundError(
627
+ raise OstructFileNotFoundError(
620
628
  f"Error reading allowed directories from file: {filepath}: {e}"
621
629
  )
622
630
 
ostruct/cli/path_utils.py CHANGED
@@ -1,17 +1,21 @@
1
1
  """Path validation utilities for the CLI."""
2
2
 
3
+ import logging
3
4
  from pathlib import Path
4
5
  from typing import Optional, Tuple
5
6
 
6
7
  from ostruct.cli.errors import (
7
8
  DirectoryNotFoundError,
8
- FileNotFoundError,
9
+ OstructFileNotFoundError,
10
+ PathSecurityError,
9
11
  VariableNameError,
10
12
  VariableValueError,
11
13
  )
12
- from ostruct.cli.security.errors import PathSecurityError, SecurityErrorReasons
14
+ from ostruct.cli.security.errors import SecurityErrorReasons
13
15
  from ostruct.cli.security.security_manager import SecurityManager
14
16
 
17
+ logger = logging.getLogger(__name__)
18
+
15
19
 
16
20
  def validate_path_mapping(
17
21
  mapping: str,
@@ -44,34 +48,52 @@ def validate_path_mapping(
44
48
  >>> validate_path_mapping("data=config/", is_dir=True) # Validates directory
45
49
  ('data', 'config/')
46
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
+
47
58
  # Split into name and path parts
48
59
  try:
49
60
  name, path_str = mapping.split("=", 1)
50
61
  except ValueError:
62
+ logger.error("Invalid mapping format (missing '='): %s", mapping)
51
63
  raise ValueError(f"Invalid mapping format (missing '='): {mapping}")
52
64
 
53
65
  # Validate name
54
66
  name = name.strip()
55
67
  if not name:
68
+ logger.error("Variable name cannot be empty: %s", mapping)
56
69
  raise VariableNameError("Variable name cannot be empty")
57
70
  if not name.isidentifier():
71
+ logger.error("Invalid variable name: %s", name)
58
72
  raise VariableNameError(f"Invalid variable name: {name}")
59
73
 
60
74
  # Normalize path
61
75
  path_str = path_str.strip()
62
76
  if not path_str:
77
+ logger.error("Path cannot be empty: %s", mapping)
63
78
  raise VariableValueError("Path cannot be empty")
64
79
 
80
+ logger.debug("Creating Path object for: %s", path_str)
65
81
  # Create a Path object
66
82
  path = Path(path_str)
67
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
+ )
68
87
  path = Path(base_dir) / path
69
88
 
70
89
  # Validate path with security manager if provided
71
90
  if security_manager:
91
+ logger.debug("Validating path with security manager: %s", path)
72
92
  try:
73
93
  path = security_manager.validate_path(path)
94
+ logger.debug("Security validation passed: %s", path)
74
95
  except PathSecurityError as e:
96
+ logger.error("Security validation failed: %s - %s", path, e)
75
97
  if (
76
98
  e.context.get("reason")
77
99
  == SecurityErrorReasons.PATH_OUTSIDE_ALLOWED
@@ -89,16 +111,22 @@ def validate_path_mapping(
89
111
 
90
112
  # Check path existence and type
91
113
  if not path.exists():
114
+ logger.error("Path does not exist: %s", path)
92
115
  if is_dir:
93
116
  raise DirectoryNotFoundError(f"Directory not found: {path}")
94
- raise FileNotFoundError(f"File not found: {path}")
117
+ raise OstructFileNotFoundError(f"File not found: {path}")
95
118
 
96
119
  # Check path type
97
120
  if is_dir and not path.is_dir():
121
+ logger.error("Path exists but is not a directory: %s", path)
98
122
  raise DirectoryNotFoundError(
99
123
  f"Path exists but is not a directory: {path}"
100
124
  )
101
125
  elif not is_dir and not path.is_file():
102
- raise FileNotFoundError(f"Path exists but is not a file: {path}")
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
+ )
103
130
 
131
+ logger.debug("Path validation successful: %s -> %s", name, path)
104
132
  return name, str(path)
@@ -25,6 +25,9 @@ def is_path_in_allowed_dirs(
25
25
  Returns:
26
26
  True if path is within one of the allowed directories; False otherwise.
27
27
 
28
+ Raises:
29
+ TypeError: If path is None or not a string/Path object.
30
+
28
31
  Example:
29
32
  >>> allowed = [Path("/base"), Path("/tmp")]
30
33
  >>> is_path_in_allowed_dirs("/base/file.txt", allowed)
@@ -32,6 +35,11 @@ def is_path_in_allowed_dirs(
32
35
  >>> is_path_in_allowed_dirs("/etc/passwd", allowed)
33
36
  False
34
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
+
35
43
  norm_path = normalize_path(path)
36
44
  norm_allowed = [normalize_path(d) for d in allowed_dirs]
37
45
 
@@ -0,0 +1,46 @@
1
+ """Base class for security-related errors."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from ostruct.cli.base_errors import CLIError
6
+ from ostruct.cli.exit_codes import ExitCode
7
+
8
+
9
+ class SecurityErrorBase(CLIError):
10
+ """Base class for security-related errors."""
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ context: Optional[Dict[str, Any]] = None,
16
+ details: Optional[str] = None,
17
+ has_been_logged: bool = False,
18
+ ) -> None:
19
+ """Initialize security error.
20
+
21
+ Args:
22
+ message: The error message.
23
+ context: Additional context for the error.
24
+ details: Detailed explanation of the error.
25
+ has_been_logged: Whether the error has been logged.
26
+ """
27
+ if context is None:
28
+ context = {}
29
+ context["category"] = "security"
30
+ super().__init__(
31
+ message,
32
+ context=context,
33
+ exit_code=ExitCode.SECURITY_ERROR,
34
+ details=details,
35
+ )
36
+ self._has_been_logged = has_been_logged
37
+
38
+ @property
39
+ def has_been_logged(self) -> bool:
40
+ """Whether this error has been logged."""
41
+ return self._has_been_logged
42
+
43
+ @has_been_logged.setter
44
+ def has_been_logged(self, value: bool) -> None:
45
+ """Set whether this error has been logged."""
46
+ self._has_been_logged = value
@@ -6,121 +6,64 @@ the security modules.
6
6
 
7
7
  from typing import Any, Dict, List, Optional
8
8
 
9
+ from .base import SecurityErrorBase
9
10
 
10
- class PathSecurityError(Exception):
11
- """Base exception for security-related errors.
12
11
 
13
- This class provides rich error information for security-related issues,
14
- including context and error wrapping capabilities.
15
- """
12
+ class PathSecurityError(SecurityErrorBase):
13
+ """Security error for path-related issues."""
16
14
 
17
15
  def __init__(
18
16
  self,
19
17
  message: str,
20
- path: str = "",
18
+ path: Optional[str] = None,
21
19
  context: Optional[Dict[str, Any]] = None,
20
+ details: Optional[str] = None,
22
21
  error_logged: bool = False,
22
+ wrapped: bool = False,
23
23
  ) -> None:
24
24
  """Initialize the error.
25
25
 
26
26
  Args:
27
27
  message: The error message.
28
28
  path: The path that caused the error.
29
- context: Additional context about the error.
30
- error_logged: Whether this error has already been logged.
29
+ context: Additional context for the error.
30
+ details: Detailed explanation of the error.
31
+ error_logged: Whether the error has been logged.
32
+ wrapped: Whether this is a wrapped error.
31
33
  """
32
- super().__init__(message)
33
- self.path = path
34
- self.context = context or {}
35
- self._error_logged = error_logged
36
- self._wrapped = False
37
-
38
- def __str__(self) -> str:
39
- """Format the error message with context if available."""
40
- msg = super().__str__()
41
-
42
- # Add expanded path information if available
43
- if self.context:
44
- if (
45
- "original_path" in self.context
46
- and "expanded_path" in self.context
47
- ):
48
- msg = (
49
- f"{msg}\n"
50
- f"Original path: {self.context['original_path']}\n"
51
- f"Expanded path: {self.context['expanded_path']}"
52
- )
53
- if "base_dir" in self.context:
54
- msg = f"{msg}\nBase directory: {self.context['base_dir']}"
55
- if "allowed_dirs" in self.context:
56
- msg = f"{msg}\nAllowed directories: {self.context['allowed_dirs']!r}"
57
-
58
- return msg
34
+ if context is None:
35
+ context = {}
36
+ if path is not None:
37
+ context["path"] = path
38
+ if details is None:
39
+ details = "The specified path violates security constraints"
40
+ context["troubleshooting"] = [
41
+ "Check if the path is within allowed directories",
42
+ "Use --allowed-dir to specify additional allowed directories",
43
+ "Verify path permissions",
44
+ ]
45
+ self._wrapped = wrapped
46
+ super().__init__(
47
+ message,
48
+ context=context,
49
+ details=details,
50
+ has_been_logged=error_logged,
51
+ )
59
52
 
60
53
  @property
61
- def has_been_logged(self) -> bool:
62
- """Whether this error has been logged."""
63
- return self._error_logged
64
-
65
- @has_been_logged.setter
66
- def has_been_logged(self, value: bool) -> None:
67
- """Set whether this error has been logged."""
68
- self._error_logged = value
54
+ def error_logged(self) -> bool:
55
+ """Alias for has_been_logged for backward compatibility."""
56
+ return self.has_been_logged
69
57
 
70
58
  @property
71
59
  def wrapped(self) -> bool:
72
- """Whether this error is wrapping another error."""
60
+ """Whether this is a wrapped error."""
73
61
  return self._wrapped
74
62
 
75
- def format_with_context(
76
- self,
77
- original_path: str,
78
- expanded_path: str,
79
- base_dir: str,
80
- allowed_dirs: List[str],
81
- ) -> str:
82
- """Format the error message with additional context.
83
-
84
- Args:
85
- original_path: The original path that caused the error
86
- expanded_path: The expanded/absolute path
87
- base_dir: The base directory for security checks
88
- allowed_dirs: List of allowed directories
89
-
90
- Returns:
91
- A formatted error message with context
92
- """
93
- lines = [
94
- str(self),
95
- f"Original path: {original_path}",
96
- f"Expanded path: {expanded_path}",
97
- f"Base directory: {base_dir}",
98
- f"Allowed directories: {allowed_dirs}",
99
- "Use --allowed-dir to add more allowed directories",
100
- ]
101
- return "\n".join(lines)
102
-
103
- @classmethod
104
- def wrap_error(
105
- cls, message: str, original: "PathSecurityError"
106
- ) -> "PathSecurityError":
107
- """Wrap an existing error with additional context.
108
-
109
- Args:
110
- message: The new error message
111
- original: The original error to wrap
112
-
113
- Returns:
114
- A new PathSecurityError instance wrapping the original
115
- """
116
- wrapped = cls(
117
- f"{message}: {str(original)}",
118
- path=original.path,
119
- context=original.context,
120
- error_logged=original.has_been_logged,
121
- )
122
- wrapped._wrapped = True
123
- return wrapped
63
+ @property
64
+ def details(self) -> str:
65
+ """Get the detailed explanation of the error."""
66
+ return self.details
124
67
 
125
68
  @classmethod
126
69
  def from_expanded_paths(
@@ -131,32 +74,69 @@ class PathSecurityError(Exception):
131
74
  allowed_dirs: List[str],
132
75
  error_logged: bool = False,
133
76
  ) -> "PathSecurityError":
134
- """Create an error instance with expanded path information.
77
+ """Create an error from expanded paths.
135
78
 
136
79
  Args:
137
- original_path: The original path that caused the error
138
- expanded_path: The expanded/absolute path
139
- base_dir: The base directory for security checks
140
- allowed_dirs: List of allowed directories
141
- error_logged: Whether this error has already been logged
80
+ original_path: The original path.
81
+ expanded_path: The expanded path.
82
+ base_dir: The base directory.
83
+ allowed_dirs: List of allowed directories.
84
+ error_logged: Whether the error has been logged.
142
85
 
143
86
  Returns:
144
- A new PathSecurityError instance with expanded path context
87
+ A new PathSecurityError instance.
145
88
  """
146
- message = f"Path '{original_path}' is outside the base directory and not in allowed directories"
147
89
  context = {
148
90
  "original_path": original_path,
149
91
  "expanded_path": expanded_path,
150
92
  "base_dir": base_dir,
151
93
  "allowed_dirs": allowed_dirs,
94
+ "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
95
+ "troubleshooting": [
96
+ "Check if the path is within allowed directories",
97
+ f"Ensure the path is within base directory: {base_dir}",
98
+ f"Current allowed directories: {', '.join(allowed_dirs)}",
99
+ ],
152
100
  }
153
101
  return cls(
154
- message,
155
- path=original_path,
102
+ "Access denied",
156
103
  context=context,
104
+ details="Path is outside allowed directories",
157
105
  error_logged=error_logged,
158
106
  )
159
107
 
108
+ @classmethod
109
+ def wrap_error(
110
+ cls, message: str, original_error: Exception
111
+ ) -> "PathSecurityError":
112
+ """Wrap another error with a security error.
113
+
114
+ Args:
115
+ message: The security error message.
116
+ original_error: The original error to wrap.
117
+
118
+ Returns:
119
+ A new PathSecurityError instance.
120
+ """
121
+ context = {
122
+ "wrapped_error": original_error.__class__.__name__,
123
+ "original_message": str(original_error),
124
+ "wrapped": True,
125
+ "troubleshooting": [
126
+ "Check if the path is within allowed directories",
127
+ "Verify path permissions",
128
+ "Check if the original error has been resolved",
129
+ ],
130
+ }
131
+ if hasattr(original_error, "context"):
132
+ context.update(original_error.context)
133
+ return cls(
134
+ message,
135
+ context=context,
136
+ wrapped=True,
137
+ error_logged=getattr(original_error, "error_logged", False),
138
+ )
139
+
160
140
 
161
141
  class DirectoryNotFoundError(PathSecurityError):
162
142
  """Raised when a directory that is expected to exist does not."""