taipanstack 0.1.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.
@@ -0,0 +1,417 @@
1
+ """
2
+ Safe filesystem operations.
3
+
4
+ Provides secure wrappers around file operations with path validation,
5
+ atomic writes, and proper error handling using Result types.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import hashlib
12
+ import os
13
+ import shutil
14
+ import tempfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TypeAlias
18
+
19
+ from taipanstack.core.result import Err, Ok, Result
20
+ from taipanstack.security.guards import SecurityError, guard_path_traversal
21
+ from taipanstack.security.sanitizers import sanitize_filename
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FileNotFoundErr:
26
+ """Error when file is not found."""
27
+
28
+ path: Path
29
+ message: str = ""
30
+
31
+ def __post_init__(self) -> None:
32
+ """Set default message."""
33
+ object.__setattr__(
34
+ self, "message", self.message or f"File not found: {self.path}"
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class NotAFileErr:
40
+ """Error when path is not a file."""
41
+
42
+ path: Path
43
+ message: str = ""
44
+
45
+ def __post_init__(self) -> None:
46
+ """Set default message."""
47
+ object.__setattr__(self, "message", self.message or f"Not a file: {self.path}")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class FileTooLargeErr:
52
+ """Error when file exceeds size limit."""
53
+
54
+ path: Path
55
+ size: int
56
+ max_size: int
57
+ message: str = ""
58
+
59
+ def __post_init__(self) -> None:
60
+ """Set default message."""
61
+ object.__setattr__(
62
+ self,
63
+ "message",
64
+ self.message or f"File too large: {self.size} bytes (max: {self.max_size})",
65
+ )
66
+
67
+
68
+ # Union type for safe_read errors
69
+ ReadFileError: TypeAlias = (
70
+ FileNotFoundErr | NotAFileErr | FileTooLargeErr | SecurityError
71
+ )
72
+
73
+
74
+ def safe_read(
75
+ path: Path | str,
76
+ *,
77
+ base_dir: Path | str | None = None,
78
+ encoding: str = "utf-8",
79
+ max_size_bytes: int | None = 10 * 1024 * 1024, # 10MB default
80
+ ) -> Result[str, ReadFileError]:
81
+ """Read a file safely with path validation.
82
+
83
+ Args:
84
+ path: Path to the file to read.
85
+ base_dir: Base directory to constrain to.
86
+ encoding: File encoding.
87
+ max_size_bytes: Maximum file size to read (None for no limit).
88
+
89
+ Returns:
90
+ Ok(str): File contents on success.
91
+ Err(ReadFileError): Error details on failure.
92
+
93
+ Example:
94
+ >>> match safe_read("config.json"):
95
+ ... case Ok(content):
96
+ ... data = json.loads(content)
97
+ ... case Err(FileNotFoundErr(path=p)):
98
+ ... print(f"Missing: {p}")
99
+ ... case Err(FileTooLargeErr(size=s)):
100
+ ... print(f"Too big: {s} bytes")
101
+
102
+ """
103
+ path = Path(path)
104
+
105
+ # Validate path
106
+ try:
107
+ if base_dir is not None:
108
+ path = guard_path_traversal(path, Path(base_dir))
109
+ elif ".." in str(path):
110
+ path = guard_path_traversal(path, Path.cwd())
111
+ except SecurityError as e:
112
+ return Err(e)
113
+
114
+ if not path.exists():
115
+ return Err(FileNotFoundErr(path=path))
116
+
117
+ if not path.is_file():
118
+ return Err(NotAFileErr(path=path))
119
+
120
+ # Check file size
121
+ if max_size_bytes is not None:
122
+ file_size = path.stat().st_size
123
+ if file_size > max_size_bytes:
124
+ return Err(
125
+ FileTooLargeErr(path=path, size=file_size, max_size=max_size_bytes)
126
+ )
127
+
128
+ return Ok(path.read_text(encoding=encoding))
129
+
130
+
131
+ def safe_write(
132
+ path: Path | str,
133
+ content: str,
134
+ *,
135
+ base_dir: Path | str | None = None,
136
+ encoding: str = "utf-8",
137
+ create_parents: bool = True,
138
+ backup: bool = True,
139
+ atomic: bool = True,
140
+ ) -> Path:
141
+ """Write to a file safely with path validation.
142
+
143
+ Args:
144
+ path: Path to write to.
145
+ content: Content to write.
146
+ base_dir: Base directory to constrain to.
147
+ encoding: File encoding.
148
+ create_parents: Create parent directories if needed.
149
+ backup: Create backup of existing file.
150
+ atomic: Use atomic write (write to temp, then rename).
151
+
152
+ Returns:
153
+ Path to the written file.
154
+
155
+ Raises:
156
+ SecurityError: If path validation fails.
157
+
158
+ """
159
+ path = Path(path)
160
+
161
+ # Validate path
162
+ if base_dir is not None:
163
+ base = Path(base_dir).resolve()
164
+ # For new files, validate the parent
165
+ if not path.exists():
166
+ parent = path.parent
167
+ guard_path_traversal(parent, base)
168
+ else:
169
+ guard_path_traversal(path, base)
170
+ elif ".." in str(path):
171
+ guard_path_traversal(path, Path.cwd())
172
+
173
+ # Sanitize filename
174
+ safe_name = sanitize_filename(path.name)
175
+ path = path.parent / safe_name
176
+
177
+ # Create parents if needed
178
+ if create_parents:
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+
181
+ # Create backup if file exists
182
+ if backup and path.exists():
183
+ backup_path = path.with_suffix(f"{path.suffix}.bak")
184
+ shutil.copy2(path, backup_path)
185
+
186
+ # Write file
187
+ if atomic:
188
+ # Write to temp file first, then rename
189
+ _fd, temp_path = tempfile.mkstemp(
190
+ dir=path.parent,
191
+ prefix=f".{path.name}.",
192
+ suffix=".tmp",
193
+ )
194
+ try:
195
+ # Close the file descriptor immediately - required for Windows
196
+ os.close(_fd)
197
+ temp_file = Path(temp_path)
198
+ temp_file.write_text(content, encoding=encoding)
199
+ # Preserve permissions if original exists
200
+ if path.exists():
201
+ shutil.copymode(path, temp_file)
202
+ # On Windows, we need to remove the target first if it exists
203
+ if path.exists():
204
+ path.unlink()
205
+ temp_file.rename(path)
206
+ except Exception:
207
+ # Clean up temp file on error
208
+ with contextlib.suppress(OSError):
209
+ Path(temp_path).unlink(missing_ok=True)
210
+ raise
211
+ else:
212
+ path.write_text(content, encoding=encoding)
213
+
214
+ return path.resolve()
215
+
216
+
217
+ def ensure_dir(
218
+ path: Path | str,
219
+ *,
220
+ base_dir: Path | str | None = None,
221
+ mode: int = 0o755,
222
+ ) -> Path:
223
+ """Ensure a directory exists, creating it if needed.
224
+
225
+ Args:
226
+ path: Path to the directory.
227
+ base_dir: Base directory to constrain to.
228
+ mode: Directory permissions.
229
+
230
+ Returns:
231
+ Path to the directory.
232
+
233
+ Raises:
234
+ SecurityError: If path validation fails.
235
+
236
+ """
237
+ path = Path(path)
238
+
239
+ # Validate path
240
+ if base_dir is not None:
241
+ path = guard_path_traversal(path, Path(base_dir), allow_symlinks=True)
242
+ elif ".." in str(path):
243
+ path = guard_path_traversal(path, Path.cwd())
244
+
245
+ path.mkdir(parents=True, exist_ok=True, mode=mode)
246
+ return path.resolve()
247
+
248
+
249
+ def safe_copy(
250
+ src: Path | str,
251
+ dst: Path | str,
252
+ *,
253
+ base_dir: Path | str | None = None,
254
+ overwrite: bool = False,
255
+ ) -> Path:
256
+ """Copy a file safely.
257
+
258
+ Args:
259
+ src: Source file path.
260
+ dst: Destination file path.
261
+ base_dir: Base directory to constrain both paths to.
262
+ overwrite: Allow overwriting existing file.
263
+
264
+ Returns:
265
+ Path to the destination file.
266
+
267
+ Raises:
268
+ SecurityError: If path validation fails.
269
+ FileExistsError: If destination exists and overwrite=False.
270
+
271
+ """
272
+ src = Path(src)
273
+ dst = Path(dst)
274
+
275
+ # Validate paths
276
+ if base_dir is not None:
277
+ base = Path(base_dir)
278
+ src = guard_path_traversal(src, base)
279
+ # For dst, validate parent if file doesn't exist
280
+ if dst.exists():
281
+ dst = guard_path_traversal(dst, base)
282
+ else:
283
+ guard_path_traversal(dst.parent, base)
284
+
285
+ if not src.exists():
286
+ raise FileNotFoundError(f"Source file not found: {src}")
287
+
288
+ if dst.exists() and not overwrite:
289
+ raise FileExistsError(f"Destination already exists: {dst}")
290
+
291
+ # Ensure parent directory exists
292
+ dst.parent.mkdir(parents=True, exist_ok=True)
293
+
294
+ shutil.copy2(src, dst)
295
+ return dst.resolve()
296
+
297
+
298
+ def safe_delete(
299
+ path: Path | str,
300
+ *,
301
+ base_dir: Path | str | None = None,
302
+ missing_ok: bool = True,
303
+ recursive: bool = False,
304
+ ) -> bool:
305
+ """Delete a file or directory safely.
306
+
307
+ Args:
308
+ path: Path to delete.
309
+ base_dir: Base directory to constrain to.
310
+ missing_ok: Don't raise if path doesn't exist.
311
+ recursive: Allow deleting directories recursively.
312
+
313
+ Returns:
314
+ True if something was deleted.
315
+
316
+ Raises:
317
+ SecurityError: If path validation fails.
318
+ FileNotFoundError: If path doesn't exist and missing_ok=False.
319
+
320
+ """
321
+ path = Path(path)
322
+
323
+ # Validate path
324
+ if base_dir is not None:
325
+ path = guard_path_traversal(path, Path(base_dir))
326
+ elif ".." in str(path):
327
+ path = guard_path_traversal(path, Path.cwd())
328
+
329
+ if not path.exists():
330
+ if missing_ok:
331
+ return False
332
+ raise FileNotFoundError(f"Path not found: {path}")
333
+
334
+ if path.is_dir():
335
+ if recursive:
336
+ shutil.rmtree(path)
337
+ else:
338
+ path.rmdir()
339
+ else:
340
+ path.unlink()
341
+
342
+ return True
343
+
344
+
345
+ def get_file_hash(
346
+ path: Path | str,
347
+ *,
348
+ algorithm: str = "sha256",
349
+ base_dir: Path | str | None = None,
350
+ ) -> str:
351
+ """Get hash of a file.
352
+
353
+ Args:
354
+ path: Path to the file.
355
+ algorithm: Hash algorithm (sha256, md5, etc).
356
+ base_dir: Base directory to constrain to.
357
+
358
+ Returns:
359
+ Hex digest of the file hash.
360
+
361
+ """
362
+ path = Path(path)
363
+
364
+ # Validate path
365
+ if base_dir is not None:
366
+ path = guard_path_traversal(path, Path(base_dir))
367
+
368
+ hasher = hashlib.new(algorithm)
369
+
370
+ with path.open("rb") as f:
371
+ for chunk in iter(lambda: f.read(8192), b""):
372
+ hasher.update(chunk)
373
+
374
+ return hasher.hexdigest()
375
+
376
+
377
+ def find_files(
378
+ directory: Path | str,
379
+ pattern: str = "*",
380
+ *,
381
+ base_dir: Path | str | None = None,
382
+ recursive: bool = True,
383
+ include_hidden: bool = False,
384
+ ) -> list[Path]:
385
+ """Find files matching a pattern.
386
+
387
+ Args:
388
+ directory: Directory to search in.
389
+ pattern: Glob pattern to match.
390
+ base_dir: Base directory to constrain to.
391
+ recursive: Search recursively.
392
+ include_hidden: Include hidden files (starting with .).
393
+
394
+ Returns:
395
+ List of matching file paths.
396
+
397
+ """
398
+ directory = Path(directory)
399
+
400
+ # Validate path
401
+ if base_dir is not None:
402
+ directory = guard_path_traversal(directory, Path(base_dir))
403
+
404
+ if not directory.exists():
405
+ return []
406
+
407
+ if recursive:
408
+ files = list(directory.rglob(pattern))
409
+ else:
410
+ files = list(directory.glob(pattern))
411
+
412
+ # Filter hidden files if needed
413
+ if not include_hidden:
414
+ files = [f for f in files if not any(p.startswith(".") for p in f.parts)]
415
+
416
+ # Only return files, not directories
417
+ return [f for f in files if f.is_file()]