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/file_list.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
"""FileInfoList implementation providing smart file content access."""
|
2
2
|
|
3
3
|
import logging
|
4
|
-
|
4
|
+
import threading
|
5
|
+
from typing import Iterable, Iterator, List, SupportsIndex, Union, overload
|
5
6
|
|
6
7
|
from .file_info import FileInfo
|
7
8
|
|
@@ -18,6 +19,12 @@ class FileInfoList(List[FileInfo]):
|
|
18
19
|
properties like content return the value directly. For multiple files or directory
|
19
20
|
mappings, properties return a list of values.
|
20
21
|
|
22
|
+
This class is thread-safe. All operations that access or modify the internal list
|
23
|
+
are protected by a reentrant lock (RLock). This allows nested method calls while
|
24
|
+
holding the lock, preventing deadlocks in cases like:
|
25
|
+
content property → __len__ → lock
|
26
|
+
content property → __getitem__ → lock
|
27
|
+
|
21
28
|
Examples:
|
22
29
|
Single file (--file):
|
23
30
|
files = FileInfoList([file_info], from_dir=False)
|
@@ -52,6 +59,7 @@ class FileInfoList(List[FileInfo]):
|
|
52
59
|
len(files),
|
53
60
|
from_dir,
|
54
61
|
)
|
62
|
+
self._lock = threading.RLock() # Use RLock for nested method calls
|
55
63
|
super().__init__(files)
|
56
64
|
self._from_dir = from_dir
|
57
65
|
|
@@ -66,20 +74,37 @@ class FileInfoList(List[FileInfo]):
|
|
66
74
|
Raises:
|
67
75
|
ValueError: If the list is empty
|
68
76
|
"""
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
77
|
+
# Take snapshot under lock
|
78
|
+
with self._lock:
|
79
|
+
if not self:
|
80
|
+
logger.debug("FileInfoList.content called but list is empty")
|
81
|
+
raise ValueError("No files in FileInfoList")
|
82
|
+
|
83
|
+
# Make a copy of the files we need to access
|
84
|
+
if len(self) == 1 and not self._from_dir:
|
85
|
+
file_info = self[0]
|
86
|
+
is_single = True
|
87
|
+
else:
|
88
|
+
files = list(self)
|
89
|
+
is_single = False
|
90
|
+
|
91
|
+
# Access file contents outside lock to prevent deadlocks
|
92
|
+
try:
|
93
|
+
if is_single:
|
94
|
+
logger.debug(
|
95
|
+
"FileInfoList.content returning single file content (not from dir)"
|
96
|
+
)
|
97
|
+
return file_info.content
|
98
|
+
|
73
99
|
logger.debug(
|
74
|
-
"FileInfoList.content returning
|
100
|
+
"FileInfoList.content returning list of %d contents (from_dir=%s)",
|
101
|
+
len(files),
|
102
|
+
self._from_dir,
|
75
103
|
)
|
76
|
-
return
|
77
|
-
|
78
|
-
"
|
79
|
-
|
80
|
-
self._from_dir,
|
81
|
-
)
|
82
|
-
return [f.content for f in self]
|
104
|
+
return [f.content for f in files]
|
105
|
+
except Exception as e:
|
106
|
+
logger.error("Error accessing file content: %s", e)
|
107
|
+
raise
|
83
108
|
|
84
109
|
@property
|
85
110
|
def path(self) -> Union[str, List[str]]:
|
@@ -92,11 +117,25 @@ class FileInfoList(List[FileInfo]):
|
|
92
117
|
Raises:
|
93
118
|
ValueError: If the list is empty
|
94
119
|
"""
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
120
|
+
# First get a snapshot of the list state under the lock
|
121
|
+
with self._lock:
|
122
|
+
if not self:
|
123
|
+
raise ValueError("No files in FileInfoList")
|
124
|
+
if len(self) == 1 and not self._from_dir:
|
125
|
+
file_info = self[0]
|
126
|
+
is_single = True
|
127
|
+
else:
|
128
|
+
files = list(self)
|
129
|
+
is_single = False
|
130
|
+
|
131
|
+
# Now access file paths outside the lock
|
132
|
+
try:
|
133
|
+
if is_single:
|
134
|
+
return file_info.path
|
135
|
+
return [f.path for f in files]
|
136
|
+
except Exception as e:
|
137
|
+
logger.error("Error accessing file path: %s", e)
|
138
|
+
raise
|
100
139
|
|
101
140
|
@property
|
102
141
|
def abs_path(self) -> Union[str, List[str]]:
|
@@ -109,11 +148,25 @@ class FileInfoList(List[FileInfo]):
|
|
109
148
|
Raises:
|
110
149
|
ValueError: If the list is empty
|
111
150
|
"""
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
151
|
+
# First get a snapshot of the list state under the lock
|
152
|
+
with self._lock:
|
153
|
+
if not self:
|
154
|
+
raise ValueError("No files in FileInfoList")
|
155
|
+
if len(self) == 1 and not self._from_dir:
|
156
|
+
file_info = self[0]
|
157
|
+
is_single = True
|
158
|
+
else:
|
159
|
+
files = list(self)
|
160
|
+
is_single = False
|
161
|
+
|
162
|
+
# Now access file paths outside the lock
|
163
|
+
try:
|
164
|
+
if is_single:
|
165
|
+
return file_info.abs_path
|
166
|
+
return [f.abs_path for f in files]
|
167
|
+
except Exception as e:
|
168
|
+
logger.error("Error accessing absolute path: %s", e)
|
169
|
+
raise
|
117
170
|
|
118
171
|
@property
|
119
172
|
def size(self) -> Union[int, List[int]]:
|
@@ -126,26 +179,39 @@ class FileInfoList(List[FileInfo]):
|
|
126
179
|
Raises:
|
127
180
|
ValueError: If the list is empty or if any file size is None
|
128
181
|
"""
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
if
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
sizes
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
182
|
+
# First get a snapshot of the list state under the lock
|
183
|
+
with self._lock:
|
184
|
+
if not self:
|
185
|
+
raise ValueError("No files in FileInfoList")
|
186
|
+
|
187
|
+
# Make a copy of the files we need to access
|
188
|
+
if len(self) == 1 and not self._from_dir:
|
189
|
+
file_info = self[0]
|
190
|
+
is_single = True
|
191
|
+
else:
|
192
|
+
files = list(self)
|
193
|
+
is_single = False
|
194
|
+
|
195
|
+
# Now access file sizes outside the lock
|
196
|
+
try:
|
197
|
+
if is_single:
|
198
|
+
size = file_info.size
|
199
|
+
if size is None:
|
200
|
+
raise ValueError(
|
201
|
+
f"Could not get size for file: {file_info.path}"
|
202
|
+
)
|
203
|
+
return size
|
204
|
+
|
205
|
+
sizes = []
|
206
|
+
for f in files:
|
207
|
+
size = f.size
|
208
|
+
if size is None:
|
209
|
+
raise ValueError(f"Could not get size for file: {f.path}")
|
210
|
+
sizes.append(size)
|
211
|
+
return sizes
|
212
|
+
except Exception as e:
|
213
|
+
logger.error("Error accessing file size: %s", e)
|
214
|
+
raise
|
149
215
|
|
150
216
|
def __str__(self) -> str:
|
151
217
|
"""Get string representation of the file list.
|
@@ -153,11 +219,12 @@ class FileInfoList(List[FileInfo]):
|
|
153
219
|
Returns:
|
154
220
|
str: String representation in format FileInfoList([paths])
|
155
221
|
"""
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
222
|
+
with self._lock:
|
223
|
+
if not self:
|
224
|
+
return "FileInfoList([])"
|
225
|
+
if len(self) == 1:
|
226
|
+
return f"FileInfoList(['{self[0].path}'])"
|
227
|
+
return f"FileInfoList({[f.path for f in self]})"
|
161
228
|
|
162
229
|
def __repr__(self) -> str:
|
163
230
|
"""Get detailed string representation of the file list.
|
@@ -169,18 +236,23 @@ class FileInfoList(List[FileInfo]):
|
|
169
236
|
|
170
237
|
def __iter__(self) -> Iterator[FileInfo]:
|
171
238
|
"""Return iterator over files."""
|
239
|
+
with self._lock:
|
240
|
+
# Create a copy of the list to avoid concurrent modification issues
|
241
|
+
items = list(super().__iter__())
|
172
242
|
logger.debug(
|
173
|
-
"Starting iteration over FileInfoList with %d files", len(
|
243
|
+
"Starting iteration over FileInfoList with %d files", len(items)
|
174
244
|
)
|
175
|
-
return
|
245
|
+
return iter(items)
|
176
246
|
|
177
247
|
def __len__(self) -> int:
|
178
248
|
"""Return number of files."""
|
179
|
-
|
249
|
+
with self._lock:
|
250
|
+
return super().__len__()
|
180
251
|
|
181
252
|
def __bool__(self) -> bool:
|
182
253
|
"""Return True if there are files."""
|
183
|
-
|
254
|
+
with self._lock:
|
255
|
+
return super().__len__() > 0
|
184
256
|
|
185
257
|
@overload
|
186
258
|
def __getitem__(self, index: SupportsIndex, /) -> FileInfo: ...
|
@@ -191,17 +263,70 @@ class FileInfoList(List[FileInfo]):
|
|
191
263
|
def __getitem__(
|
192
264
|
self, index: Union[SupportsIndex, slice], /
|
193
265
|
) -> Union[FileInfo, "FileInfoList"]:
|
194
|
-
"""Get file at index.
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
266
|
+
"""Get file at index.
|
267
|
+
|
268
|
+
This method is thread-safe and handles both integer indexing and slicing.
|
269
|
+
For slicing, it ensures the result is always converted to a list before
|
270
|
+
creating a new FileInfoList instance.
|
271
|
+
"""
|
272
|
+
with self._lock:
|
273
|
+
logger.debug("Getting file at index %s", index)
|
274
|
+
result = super().__getitem__(index)
|
275
|
+
if isinstance(index, slice):
|
276
|
+
# Always convert to list to handle any sequence type
|
277
|
+
# Cast to Iterable[FileInfo] to satisfy mypy
|
278
|
+
result_list = list(
|
279
|
+
result if isinstance(result, list) else [result]
|
280
|
+
)
|
281
|
+
return FileInfoList(result_list, self._from_dir)
|
282
|
+
if not isinstance(result, FileInfo):
|
199
283
|
raise TypeError(
|
200
|
-
f"Expected
|
284
|
+
f"Expected FileInfo from index, got {type(result)}"
|
201
285
|
)
|
202
|
-
return
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
286
|
+
return result
|
287
|
+
|
288
|
+
def append(self, value: FileInfo) -> None:
|
289
|
+
"""Append a FileInfo object to the list."""
|
290
|
+
with self._lock:
|
291
|
+
super().append(value)
|
292
|
+
|
293
|
+
def extend(self, values: Iterable[FileInfo]) -> None:
|
294
|
+
"""Extend the list with FileInfo objects.
|
295
|
+
|
296
|
+
Args:
|
297
|
+
values: Iterable of FileInfo objects to add
|
298
|
+
"""
|
299
|
+
with self._lock:
|
300
|
+
super().extend(values)
|
301
|
+
|
302
|
+
def insert(self, index: SupportsIndex, value: FileInfo) -> None:
|
303
|
+
"""Insert a FileInfo object at the given index.
|
304
|
+
|
305
|
+
Args:
|
306
|
+
index: Position to insert at
|
307
|
+
value: FileInfo object to insert
|
308
|
+
"""
|
309
|
+
with self._lock:
|
310
|
+
super().insert(index, value)
|
311
|
+
|
312
|
+
def pop(self, index: SupportsIndex = -1) -> FileInfo:
|
313
|
+
"""Remove and return item at index (default last).
|
314
|
+
|
315
|
+
Args:
|
316
|
+
index: Position to remove from (default: -1 for last item)
|
317
|
+
|
318
|
+
Returns:
|
319
|
+
The removed FileInfo object
|
320
|
+
"""
|
321
|
+
with self._lock:
|
322
|
+
return super().pop(index)
|
323
|
+
|
324
|
+
def remove(self, value: FileInfo) -> None:
|
325
|
+
"""Remove first occurrence of value."""
|
326
|
+
with self._lock:
|
327
|
+
super().remove(value)
|
328
|
+
|
329
|
+
def clear(self) -> None:
|
330
|
+
"""Remove all items from list."""
|
331
|
+
with self._lock:
|
332
|
+
super().clear()
|
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 .
|
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
|
162
|
-
allowed_extensions: List of allowed file extensions without
|
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
|
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
|
-
#
|
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
|
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.
|
191
|
-
|
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.
|
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
|
-
|
211
|
-
|
212
|
-
|
213
|
-
)
|
214
|
-
|
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
|
-
|
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
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
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
|
228
|
-
abs_path,
|
229
|
-
str(e),
|
212
|
+
"Skipping subdirectory (non-recursive mode): %s", root
|
230
213
|
)
|
231
214
|
continue
|
232
215
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
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
|
-
"
|
239
|
-
|
240
|
-
|
241
|
-
|
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
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
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
|