ostruct-cli 0.3.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/cli.py +84 -118
- ostruct/cli/click_options.py +54 -45
- ostruct/cli/errors.py +63 -170
- ostruct/cli/file_info.py +98 -57
- ostruct/cli/file_list.py +189 -64
- ostruct/cli/file_utils.py +93 -66
- 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-0.3.0.dist-info → ostruct_cli-0.4.0.dist-info}/METADATA +6 -5
- ostruct_cli-0.4.0.dist-info/RECORD +36 -0
- 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.4.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.4.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.4.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/errors.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1
1
|
"""Custom error classes for CLI error handling."""
|
2
2
|
|
3
|
-
import
|
4
|
-
from pathlib import Path
|
3
|
+
import logging
|
5
4
|
from typing import Any, Dict, List, Optional, TextIO, cast
|
6
5
|
|
7
6
|
import click
|
8
7
|
|
8
|
+
from .security.errors import PathSecurityError as SecurityPathSecurityError
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
9
12
|
|
10
13
|
class CLIError(click.ClickException):
|
11
14
|
"""Base class for all CLI errors."""
|
@@ -96,6 +99,19 @@ class FileNotFoundError(PathError):
|
|
96
99
|
super().__init__(path, path, context)
|
97
100
|
|
98
101
|
|
102
|
+
class FileReadError(PathError):
|
103
|
+
"""Raised when a file cannot be read or decoded.
|
104
|
+
|
105
|
+
This is a wrapper exception that preserves the original cause (FileNotFoundError,
|
106
|
+
UnicodeDecodeError, etc) while providing a consistent interface for error handling.
|
107
|
+
"""
|
108
|
+
|
109
|
+
def __init__(
|
110
|
+
self, message: str, path: str, context: Optional[Dict[str, Any]] = None
|
111
|
+
):
|
112
|
+
super().__init__(message, path, context)
|
113
|
+
|
114
|
+
|
99
115
|
class DirectoryNotFoundError(PathError):
|
100
116
|
"""Raised when a specified directory does not exist."""
|
101
117
|
|
@@ -104,12 +120,11 @@ class DirectoryNotFoundError(PathError):
|
|
104
120
|
super().__init__(path, path, context)
|
105
121
|
|
106
122
|
|
107
|
-
class PathSecurityError(
|
108
|
-
"""
|
123
|
+
class PathSecurityError(CLIError, SecurityPathSecurityError):
|
124
|
+
"""CLI wrapper for security package's PathSecurityError.
|
109
125
|
|
110
|
-
|
111
|
-
|
112
|
-
wrapped: Whether this error has been wrapped by another error
|
126
|
+
This class bridges the security package's error handling with the CLI's
|
127
|
+
error handling system, providing both sets of functionality.
|
113
128
|
"""
|
114
129
|
|
115
130
|
def __init__(
|
@@ -119,188 +134,65 @@ class PathSecurityError(PathError):
|
|
119
134
|
context: Optional[Dict[str, Any]] = None,
|
120
135
|
error_logged: bool = False,
|
121
136
|
):
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
137
|
+
"""Initialize both parent classes properly.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
message: The error message
|
141
|
+
path: The path that caused the error
|
142
|
+
context: Additional context about the error
|
143
|
+
error_logged: Whether this error has been logged
|
144
|
+
"""
|
145
|
+
# Initialize security error first
|
146
|
+
SecurityPathSecurityError.__init__(
|
147
|
+
self,
|
148
|
+
message,
|
149
|
+
path=path or "",
|
150
|
+
context=context,
|
151
|
+
error_logged=error_logged,
|
152
|
+
)
|
153
|
+
# Initialize CLI error with the same context
|
154
|
+
CLIError.__init__(self, message, context=self.context)
|
155
|
+
# Ensure error_logged state is consistent
|
156
|
+
self._has_been_logged = error_logged
|
157
|
+
logger.debug(
|
158
|
+
"Created CLI PathSecurityError with message=%r, path=%r, context=%r, error_logged=%r",
|
159
|
+
message,
|
160
|
+
path,
|
161
|
+
self.context,
|
162
|
+
error_logged,
|
126
163
|
)
|
127
|
-
context["wrapped"] = False # Initialize wrapped state
|
128
|
-
super().__init__(message, path, context)
|
129
164
|
|
130
|
-
def
|
131
|
-
"""
|
132
|
-
|
133
|
-
|
134
|
-
allowed = self.context["allowed_dirs"]
|
135
|
-
if allowed: # Only add if there are actually allowed dirs
|
136
|
-
return f"{base} (allowed directories: {', '.join(allowed)})"
|
137
|
-
return base
|
165
|
+
def show(self, file: Optional[TextIO] = None) -> None:
|
166
|
+
"""Show the error with CLI formatting."""
|
167
|
+
logger.debug("Showing error with context: %r", self.context)
|
168
|
+
super().show(file)
|
138
169
|
|
139
170
|
@property
|
140
171
|
def has_been_logged(self) -> bool:
|
141
172
|
"""Whether this error has been logged."""
|
142
|
-
return
|
173
|
+
return self._has_been_logged or super().has_been_logged
|
143
174
|
|
144
175
|
@has_been_logged.setter
|
145
176
|
def has_been_logged(self, value: bool) -> None:
|
146
177
|
"""Set whether this error has been logged."""
|
147
|
-
self.
|
178
|
+
self._has_been_logged = value
|
179
|
+
super().has_been_logged = value # type: ignore[misc]
|
148
180
|
|
149
181
|
@property
|
150
182
|
def error_logged(self) -> bool:
|
151
|
-
"""
|
183
|
+
"""Whether this error has been logged (alias for has_been_logged)."""
|
152
184
|
return self.has_been_logged
|
153
185
|
|
154
186
|
@error_logged.setter
|
155
187
|
def error_logged(self, value: bool) -> None:
|
156
|
-
"""
|
188
|
+
"""Set whether this error has been logged (alias for has_been_logged)."""
|
157
189
|
self.has_been_logged = value
|
158
190
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
@wrapped.setter
|
165
|
-
def wrapped(self, value: bool) -> None:
|
166
|
-
"""Set whether this error has been wrapped."""
|
167
|
-
self.context["wrapped"] = value
|
168
|
-
|
169
|
-
@staticmethod
|
170
|
-
def _format_allowed_dirs(allowed_dirs: List[str]) -> str:
|
171
|
-
"""Format allowed directories as a list representation."""
|
172
|
-
return f"[{', '.join(repr(d) for d in allowed_dirs)}]"
|
173
|
-
|
174
|
-
@staticmethod
|
175
|
-
def _create_error_message(
|
176
|
-
path: str, base_dir: Optional[str] = None
|
177
|
-
) -> str:
|
178
|
-
"""Create a standardized error message."""
|
179
|
-
if base_dir:
|
180
|
-
rel_path = os.path.relpath(path, base_dir)
|
181
|
-
return f"Access denied: {rel_path} is outside base directory and not in allowed directories"
|
182
|
-
return f"Access denied: {path} is outside base directory and not in allowed directories"
|
183
|
-
|
184
|
-
@classmethod
|
185
|
-
def from_expanded_paths(
|
186
|
-
cls,
|
187
|
-
original_path: str,
|
188
|
-
expanded_path: str,
|
189
|
-
base_dir: Optional[str] = None,
|
190
|
-
allowed_dirs: Optional[List[str]] = None,
|
191
|
-
error_logged: bool = False,
|
192
|
-
) -> "PathSecurityError":
|
193
|
-
"""Create error with expanded path context."""
|
194
|
-
message = f"Access denied: {original_path} is outside base directory and not in allowed directories"
|
195
|
-
if expanded_path != original_path:
|
196
|
-
message += f" (expanded to {expanded_path})"
|
197
|
-
|
198
|
-
context = {
|
199
|
-
"original_path": original_path,
|
200
|
-
"expanded_path": expanded_path,
|
201
|
-
"has_been_logged": error_logged,
|
202
|
-
}
|
203
|
-
if base_dir:
|
204
|
-
context["base_dir"] = base_dir
|
205
|
-
if allowed_dirs:
|
206
|
-
context["allowed_dirs"] = allowed_dirs
|
207
|
-
|
208
|
-
# Format full message with all context
|
209
|
-
parts = [message]
|
210
|
-
if base_dir:
|
211
|
-
parts.append(f"Base directory: {base_dir}")
|
212
|
-
if allowed_dirs:
|
213
|
-
parts.append(
|
214
|
-
f"Allowed directories: {cls._format_allowed_dirs(allowed_dirs)}"
|
215
|
-
)
|
216
|
-
parts.append(
|
217
|
-
"Use --allowed-dir to specify additional allowed directories"
|
218
|
-
)
|
219
|
-
|
220
|
-
return cls(
|
221
|
-
"\n".join(parts),
|
222
|
-
path=original_path,
|
223
|
-
context=context,
|
224
|
-
error_logged=error_logged,
|
225
|
-
)
|
226
|
-
|
227
|
-
@classmethod
|
228
|
-
def access_denied(
|
229
|
-
cls,
|
230
|
-
path: Path,
|
231
|
-
reason: Optional[str] = None,
|
232
|
-
error_logged: bool = False,
|
233
|
-
) -> "PathSecurityError":
|
234
|
-
"""Create access denied error."""
|
235
|
-
msg = f"Access denied: {path}"
|
236
|
-
if reason:
|
237
|
-
msg += f" - {reason}"
|
238
|
-
msg += " is outside base directory and not in allowed directories"
|
239
|
-
return cls(msg, path=str(path), error_logged=error_logged)
|
240
|
-
|
241
|
-
@classmethod
|
242
|
-
def outside_allowed(
|
243
|
-
cls,
|
244
|
-
path: Path,
|
245
|
-
base_dir: Optional[Path] = None,
|
246
|
-
error_logged: bool = False,
|
247
|
-
) -> "PathSecurityError":
|
248
|
-
"""Create error for path outside allowed directories."""
|
249
|
-
msg = f"Access denied: {path} is outside base directory and not in allowed directories"
|
250
|
-
context = {}
|
251
|
-
if base_dir:
|
252
|
-
context["base_directory"] = str(base_dir)
|
253
|
-
return cls(
|
254
|
-
msg, path=str(path), context=context, error_logged=error_logged
|
255
|
-
)
|
256
|
-
|
257
|
-
@classmethod
|
258
|
-
def traversal_attempt(
|
259
|
-
cls, path: Path, error_logged: bool = False
|
260
|
-
) -> "PathSecurityError":
|
261
|
-
"""Create error for directory traversal attempt."""
|
262
|
-
msg = f"Access denied: {path} - directory traversal not allowed"
|
263
|
-
return cls(msg, path=str(path), error_logged=error_logged)
|
264
|
-
|
265
|
-
def format_with_context(
|
266
|
-
self,
|
267
|
-
original_path: Optional[str] = None,
|
268
|
-
expanded_path: Optional[str] = None,
|
269
|
-
base_dir: Optional[str] = None,
|
270
|
-
allowed_dirs: Optional[List[str]] = None,
|
271
|
-
) -> str:
|
272
|
-
"""Format error message with additional context."""
|
273
|
-
parts = [self.message]
|
274
|
-
if original_path and expanded_path and original_path != expanded_path:
|
275
|
-
parts.append(f"Original path: {original_path}")
|
276
|
-
parts.append(f"Expanded path: {expanded_path}")
|
277
|
-
if base_dir:
|
278
|
-
parts.append(f"Base directory: {base_dir}")
|
279
|
-
if allowed_dirs:
|
280
|
-
parts.append(
|
281
|
-
f"Allowed directories: {self._format_allowed_dirs(allowed_dirs)}"
|
282
|
-
)
|
283
|
-
parts.append(
|
284
|
-
"Use --allowed-dir to specify additional allowed directories"
|
285
|
-
)
|
286
|
-
return "\n".join(parts)
|
287
|
-
|
288
|
-
@classmethod
|
289
|
-
def wrap_error(
|
290
|
-
cls, context: str, original: "PathSecurityError"
|
291
|
-
) -> "PathSecurityError":
|
292
|
-
"""Wrap an error with additional context while preserving attributes."""
|
293
|
-
message = f"{context}: {original.message}"
|
294
|
-
new_context = original.context.copy()
|
295
|
-
new_context["wrapped"] = True # Mark as wrapped
|
296
|
-
error = cls(
|
297
|
-
message,
|
298
|
-
path=original.context.get("path", "unknown"),
|
299
|
-
context=new_context,
|
300
|
-
error_logged=original.has_been_logged,
|
301
|
-
)
|
302
|
-
error.wrapped = True # Ensure wrapped is set through the property
|
303
|
-
return error
|
191
|
+
def __str__(self) -> str:
|
192
|
+
"""Format the error message with context."""
|
193
|
+
msg = SecurityPathSecurityError.__str__(self)
|
194
|
+
logger.debug("Formatted error message: %r", msg)
|
195
|
+
return msg
|
304
196
|
|
305
197
|
|
306
198
|
class TaskTemplateError(CLIError):
|
@@ -470,6 +362,7 @@ __all__ = [
|
|
470
362
|
"PathError",
|
471
363
|
"PathSecurityError",
|
472
364
|
"FileNotFoundError",
|
365
|
+
"FileReadError",
|
473
366
|
"DirectoryNotFoundError",
|
474
367
|
"SchemaValidationError",
|
475
368
|
"SchemaFileError",
|
ostruct/cli/file_info.py
CHANGED
@@ -5,7 +5,7 @@ import logging
|
|
5
5
|
import os
|
6
6
|
from typing import Any, Optional
|
7
7
|
|
8
|
-
from .errors import FileNotFoundError, PathSecurityError
|
8
|
+
from .errors import FileNotFoundError, FileReadError, PathSecurityError
|
9
9
|
from .security import SecurityManager
|
10
10
|
|
11
11
|
logger = logging.getLogger(__name__)
|
@@ -45,6 +45,7 @@ class FileInfo:
|
|
45
45
|
Raises:
|
46
46
|
FileNotFoundError: If the file does not exist
|
47
47
|
PathSecurityError: If the path is not allowed
|
48
|
+
PermissionError: If access is denied
|
48
49
|
"""
|
49
50
|
logger.debug("Creating FileInfo for path: %s", path)
|
50
51
|
|
@@ -53,7 +54,7 @@ class FileInfo:
|
|
53
54
|
raise ValueError("Path cannot be empty")
|
54
55
|
|
55
56
|
# Initialize private attributes
|
56
|
-
self.__path =
|
57
|
+
self.__path = str(path)
|
57
58
|
self.__security_manager = security_manager
|
58
59
|
self.__content = content
|
59
60
|
self.__encoding = encoding
|
@@ -61,44 +62,67 @@ class FileInfo:
|
|
61
62
|
self.__size: Optional[int] = None
|
62
63
|
self.__mtime: Optional[float] = None
|
63
64
|
|
64
|
-
# First validate security and resolve path
|
65
65
|
try:
|
66
66
|
# This will raise PathSecurityError if path is not allowed
|
67
|
+
# And FileNotFoundError if the file doesn't exist
|
67
68
|
resolved_path = self.__security_manager.resolve_path(self.__path)
|
68
69
|
logger.debug(
|
69
70
|
"Security-resolved path for %s: %s", path, resolved_path
|
70
71
|
)
|
71
72
|
|
72
|
-
#
|
73
|
-
if not resolved_path.exists():
|
74
|
-
# Use the original path in the error message
|
75
|
-
raise FileNotFoundError(
|
76
|
-
f"File not found: {os.path.basename(path)}"
|
77
|
-
)
|
78
|
-
|
73
|
+
# Check if it's a regular file (not a directory, device, etc.)
|
79
74
|
if not resolved_path.is_file():
|
75
|
+
logger.debug("Not a regular file: %s", resolved_path)
|
80
76
|
raise FileNotFoundError(
|
81
|
-
f"Not a file: {os.path.basename(path)}"
|
77
|
+
f"Not a regular file: {os.path.basename(str(path))}"
|
82
78
|
)
|
83
79
|
|
84
|
-
except PathSecurityError:
|
85
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
80
|
+
except PathSecurityError as e:
|
81
|
+
# Let security errors propagate directly with context
|
82
|
+
logger.error(
|
83
|
+
"Security error accessing file %s: %s",
|
84
|
+
path,
|
85
|
+
str(e),
|
86
|
+
extra={
|
87
|
+
"path": path,
|
88
|
+
"resolved_path": (
|
89
|
+
str(resolved_path)
|
90
|
+
if "resolved_path" in locals()
|
91
|
+
else None
|
92
|
+
),
|
93
|
+
"base_dir": str(self.__security_manager.base_dir),
|
94
|
+
"allowed_dirs": [
|
95
|
+
str(d) for d in self.__security_manager.allowed_dirs
|
96
|
+
],
|
97
|
+
},
|
91
98
|
)
|
92
|
-
|
93
|
-
|
99
|
+
raise
|
100
|
+
|
101
|
+
except FileNotFoundError as e:
|
102
|
+
# Re-raise with standardized message format
|
103
|
+
logger.debug("File not found error: %s", e)
|
94
104
|
raise FileNotFoundError(
|
95
|
-
f"File not found: {os.path.basename(path)}"
|
96
|
-
)
|
105
|
+
f"File not found: {os.path.basename(str(path))}"
|
106
|
+
) from e
|
97
107
|
|
98
|
-
|
99
|
-
|
100
|
-
logger.
|
101
|
-
|
108
|
+
except PermissionError as e:
|
109
|
+
# Handle permission errors with context
|
110
|
+
logger.error(
|
111
|
+
"Permission denied accessing file %s: %s",
|
112
|
+
path,
|
113
|
+
str(e),
|
114
|
+
extra={
|
115
|
+
"path": path,
|
116
|
+
"resolved_path": (
|
117
|
+
str(resolved_path)
|
118
|
+
if "resolved_path" in locals()
|
119
|
+
else None
|
120
|
+
),
|
121
|
+
},
|
122
|
+
)
|
123
|
+
raise PermissionError(
|
124
|
+
f"Permission denied: {os.path.basename(str(path))}"
|
125
|
+
) from e
|
102
126
|
|
103
127
|
@property
|
104
128
|
def path(self) -> str:
|
@@ -154,7 +178,8 @@ class FileInfo:
|
|
154
178
|
"""Get file size in bytes."""
|
155
179
|
if self.__size is None:
|
156
180
|
try:
|
157
|
-
|
181
|
+
size = os.path.getsize(self.abs_path)
|
182
|
+
self.__size = size
|
158
183
|
except OSError:
|
159
184
|
logger.warning("Could not get size for %s", self.__path)
|
160
185
|
return None
|
@@ -170,7 +195,8 @@ class FileInfo:
|
|
170
195
|
"""Get file modification time as Unix timestamp."""
|
171
196
|
if self.__mtime is None:
|
172
197
|
try:
|
173
|
-
|
198
|
+
mtime = os.path.getmtime(self.abs_path)
|
199
|
+
self.__mtime = mtime
|
174
200
|
except OSError:
|
175
201
|
logger.warning("Could not get mtime for %s", self.__path)
|
176
202
|
return None
|
@@ -183,10 +209,25 @@ class FileInfo:
|
|
183
209
|
|
184
210
|
@property
|
185
211
|
def content(self) -> str:
|
186
|
-
"""Get the content of the file.
|
212
|
+
"""Get the content of the file.
|
213
|
+
|
214
|
+
Returns:
|
215
|
+
str: The file content
|
216
|
+
|
217
|
+
Raises:
|
218
|
+
FileReadError: If the file cannot be read, wrapping the underlying cause
|
219
|
+
(FileNotFoundError, UnicodeDecodeError, etc)
|
220
|
+
"""
|
221
|
+
if self.__content is None:
|
222
|
+
try:
|
223
|
+
self._read_file()
|
224
|
+
except Exception as e:
|
225
|
+
raise FileReadError(
|
226
|
+
f"Failed to load content: {self.__path}", self.__path
|
227
|
+
) from e
|
187
228
|
assert (
|
188
229
|
self.__content is not None
|
189
|
-
)
|
230
|
+
) # Help mypy understand content is set
|
190
231
|
return self.__content
|
191
232
|
|
192
233
|
@content.setter
|
@@ -196,10 +237,20 @@ class FileInfo:
|
|
196
237
|
|
197
238
|
@property
|
198
239
|
def encoding(self) -> str:
|
199
|
-
"""Get the encoding of the file.
|
240
|
+
"""Get the encoding of the file.
|
241
|
+
|
242
|
+
Returns:
|
243
|
+
str: The file encoding (utf-8 or system)
|
244
|
+
|
245
|
+
Raises:
|
246
|
+
FileReadError: If the file cannot be read or decoded
|
247
|
+
"""
|
248
|
+
if self.__encoding is None:
|
249
|
+
# This will trigger content loading and may raise FileReadError
|
250
|
+
self.content
|
200
251
|
assert (
|
201
252
|
self.__encoding is not None
|
202
|
-
)
|
253
|
+
) # Help mypy understand encoding is set
|
203
254
|
return self.__encoding
|
204
255
|
|
205
256
|
@encoding.setter
|
@@ -248,34 +299,24 @@ class FileInfo:
|
|
248
299
|
return False
|
249
300
|
|
250
301
|
def _read_file(self) -> None:
|
251
|
-
"""Read
|
302
|
+
"""Read and decode file content.
|
303
|
+
|
304
|
+
Implementation detail: Attempts UTF-8 first, falls back to system encoding.
|
305
|
+
All exceptions will be caught and wrapped by the content property.
|
306
|
+
"""
|
252
307
|
try:
|
253
308
|
with open(self.abs_path, "rb") as f:
|
254
309
|
raw_content = f.read()
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
self.__encoding = "utf-8"
|
266
|
-
return
|
267
|
-
except UnicodeDecodeError:
|
268
|
-
pass
|
269
|
-
|
270
|
-
# Fall back to system default encoding
|
271
|
-
try:
|
272
|
-
self.__content = raw_content.decode()
|
273
|
-
self.__encoding = "system"
|
274
|
-
return
|
275
|
-
except UnicodeDecodeError as e:
|
276
|
-
raise ValueError(
|
277
|
-
f"Could not decode file {self.__path}: {e}"
|
278
|
-
) from e
|
310
|
+
try:
|
311
|
+
self.__content = raw_content.decode("utf-8")
|
312
|
+
self.__encoding = "utf-8"
|
313
|
+
except UnicodeDecodeError:
|
314
|
+
# Fall back to system encoding
|
315
|
+
self.__content = raw_content.decode()
|
316
|
+
self.__encoding = "system"
|
317
|
+
except Exception:
|
318
|
+
# Let content property handle all errors
|
319
|
+
raise
|
279
320
|
|
280
321
|
def update_cache(
|
281
322
|
self,
|