wslshot 0.0.12__py3-none-any.whl → 0.1.1__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.
- wslshot/cli.py +2067 -302
- wslshot/exceptions.py +25 -0
- wslshot-0.1.1.dist-info/METADATA +282 -0
- wslshot-0.1.1.dist-info/RECORD +7 -0
- {wslshot-0.0.12.dist-info → wslshot-0.1.1.dist-info}/WHEEL +1 -2
- {wslshot-0.0.12.dist-info → wslshot-0.1.1.dist-info}/entry_points.txt +1 -0
- wslshot-0.0.12.dist-info/LICENSE +0 -201
- wslshot-0.0.12.dist-info/METADATA +0 -175
- wslshot-0.0.12.dist-info/RECORD +0 -8
- wslshot-0.0.12.dist-info/top_level.txt +0 -1
wslshot/cli.py
CHANGED
|
@@ -10,246 +10,1606 @@ Features:
|
|
|
10
10
|
- Specify the number of screenshots to be processed with the '--count' option.
|
|
11
11
|
- Customize the source directory using '--source'.
|
|
12
12
|
- Customize the destination directory using '--destination'.
|
|
13
|
-
- Choose your preferred output
|
|
13
|
+
- Choose your preferred output style (Markdown, HTML, or text) with the '--output-style' option.
|
|
14
|
+
- Convert screenshots to png, jpg/jpeg, webp, or gif with the '--convert-to' option or a configured default.
|
|
14
15
|
- Configure default settings with the 'configure' subcommand.
|
|
15
16
|
|
|
16
17
|
For detailed usage instructions, use 'wslshot --help' or 'wslshot [command] --help'.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
import datetime
|
|
21
20
|
import json
|
|
21
|
+
import os
|
|
22
22
|
import shutil
|
|
23
23
|
import subprocess
|
|
24
24
|
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import uuid
|
|
27
|
+
import warnings
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
25
30
|
from pathlib import Path
|
|
26
|
-
from
|
|
31
|
+
from stat import S_ISDIR, S_ISLNK, S_ISREG
|
|
27
32
|
|
|
28
33
|
import click
|
|
29
34
|
from click_default_group import DefaultGroup
|
|
35
|
+
from PIL import Image
|
|
36
|
+
|
|
37
|
+
from wslshot.exceptions import (
|
|
38
|
+
ConfigurationError,
|
|
39
|
+
GitError,
|
|
40
|
+
ScreenshotNotFoundError,
|
|
41
|
+
SecurityError,
|
|
42
|
+
ValidationError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# CLI message prefixes (styled, user-facing)
|
|
46
|
+
SECURITY_ERROR_PREFIX = click.style("Security error:", fg="red")
|
|
47
|
+
WARNING_PREFIX = click.style("Warning:", fg="yellow")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ============================================================================
|
|
51
|
+
# Constants
|
|
52
|
+
# ============================================================================
|
|
53
|
+
|
|
54
|
+
# File permissions
|
|
55
|
+
CONFIG_FILE_PERMISSIONS = 0o600
|
|
56
|
+
CONFIG_DIR_PERMISSIONS = 0o700
|
|
57
|
+
FILE_PERMISSION_MASK = 0o777
|
|
58
|
+
|
|
59
|
+
# Config file location (relative parts only; Path.home() evaluated at runtime)
|
|
60
|
+
CONFIG_DIR_RELATIVE = Path(".config") / "wslshot"
|
|
61
|
+
CONFIG_FILE_NAME = "config.json"
|
|
62
|
+
|
|
63
|
+
# Output formats
|
|
64
|
+
OUTPUT_FORMAT_MARKDOWN = "markdown"
|
|
65
|
+
OUTPUT_FORMAT_HTML = "html"
|
|
66
|
+
OUTPUT_FORMAT_TEXT = "text"
|
|
67
|
+
DEFAULT_OUTPUT_FORMAT = OUTPUT_FORMAT_MARKDOWN
|
|
68
|
+
VALID_OUTPUT_FORMATS = (OUTPUT_FORMAT_MARKDOWN, OUTPUT_FORMAT_HTML, OUTPUT_FORMAT_TEXT)
|
|
69
|
+
OUTPUT_FORMATS_HELP = ", ".join(VALID_OUTPUT_FORMATS)
|
|
70
|
+
LEGACY_OUTPUT_FORMAT_PLAIN_TEXT = "plain_text"
|
|
71
|
+
|
|
72
|
+
# Git image directory detection (priority order)
|
|
73
|
+
GIT_IMAGE_DIRECTORY_PRIORITY = (
|
|
74
|
+
("img",),
|
|
75
|
+
("images",),
|
|
76
|
+
("assets", "img"),
|
|
77
|
+
("assets", "images"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Hard maximum limits (non-bypassable security ceilings)
|
|
81
|
+
# Config values are clamped to these limits to prevent DoS attacks
|
|
82
|
+
HARD_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB per file
|
|
83
|
+
HARD_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024 # 200MB aggregate
|
|
84
|
+
|
|
85
|
+
# Default limits (configurable but clamped to hard ceilings)
|
|
86
|
+
MAX_IMAGE_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB
|
|
87
|
+
MAX_TOTAL_IMAGE_SIZE_BYTES = 200 * 1024 * 1024 # 200MB
|
|
88
|
+
# Pillow's decompression bomb warning threshold (89.478M pixels)
|
|
89
|
+
# Images exceeding this are potential DoS vectors even if under file size limit
|
|
90
|
+
MAX_IMAGE_PIXELS = 89_478_485
|
|
91
|
+
PNG_TRAILER = b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"
|
|
92
|
+
JPEG_TRAILER = b"\xff\xd9"
|
|
93
|
+
GIF_TRAILER = b"\x3b"
|
|
94
|
+
|
|
95
|
+
# Valid conversion target formats (lowercase, without dot)
|
|
96
|
+
VALID_CONVERT_FORMATS = ("png", "jpg", "jpeg", "webp", "gif")
|
|
97
|
+
|
|
98
|
+
# Supported image file extensions (lowercase)
|
|
99
|
+
SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif")
|
|
100
|
+
|
|
101
|
+
# Image conversion quality (JPEG/WebP)
|
|
102
|
+
IMAGE_SAVE_QUALITY = 95
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_optional_directory(value: object) -> str:
|
|
106
|
+
if value is None:
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
if isinstance(value, Path):
|
|
110
|
+
value = str(value)
|
|
111
|
+
|
|
112
|
+
if not isinstance(value, str):
|
|
113
|
+
raise TypeError("Directory path must be a string.")
|
|
114
|
+
|
|
115
|
+
if not value.strip():
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
return str(resolve_path_safely(value))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_bool(value: object) -> bool:
|
|
122
|
+
if isinstance(value, bool):
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
normalized = value.strip().casefold()
|
|
127
|
+
if normalized in {"true", "1", "yes", "y", "on"}:
|
|
128
|
+
return True
|
|
129
|
+
if normalized in {"false", "0", "no", "n", "off"}:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
raise TypeError("Boolean value must be a bool.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalize_output_format(value: object) -> str:
|
|
136
|
+
if not isinstance(value, str):
|
|
137
|
+
raise TypeError("Output format must be a string.")
|
|
138
|
+
|
|
139
|
+
normalized = value.casefold()
|
|
140
|
+
if normalized not in VALID_OUTPUT_FORMATS:
|
|
141
|
+
suggestion = suggest_format(value, list(VALID_OUTPUT_FORMATS))
|
|
142
|
+
valid_options = ", ".join(VALID_OUTPUT_FORMATS)
|
|
143
|
+
message = f"Invalid `--output-style`: {value}. Use one of: {valid_options}."
|
|
144
|
+
if suggestion:
|
|
145
|
+
message = f"{message} {suggestion}"
|
|
146
|
+
raise ValueError(message)
|
|
147
|
+
|
|
148
|
+
return normalized
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalize_default_convert_to(value: object) -> str | None:
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
if not isinstance(value, str):
|
|
156
|
+
raise TypeError("Conversion format must be a string or None.")
|
|
157
|
+
|
|
158
|
+
normalized = value.strip().lower().replace(".", "")
|
|
159
|
+
if not normalized:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if normalized not in VALID_CONVERT_FORMATS:
|
|
163
|
+
valid_options = ", ".join(VALID_CONVERT_FORMATS)
|
|
164
|
+
suggestion = suggest_format(normalized, list(VALID_CONVERT_FORMATS))
|
|
165
|
+
message = f"Invalid `--convert-to`: {value}. Use one of: {valid_options}."
|
|
166
|
+
if suggestion:
|
|
167
|
+
message = f"{message} {suggestion}"
|
|
168
|
+
raise ValueError(message)
|
|
169
|
+
|
|
170
|
+
return normalized
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def normalize_int(value: object) -> int:
|
|
174
|
+
if isinstance(value, bool):
|
|
175
|
+
raise TypeError("Value must be an int.")
|
|
176
|
+
|
|
177
|
+
if isinstance(value, int):
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
if isinstance(value, str):
|
|
181
|
+
stripped = value.strip()
|
|
182
|
+
if not stripped:
|
|
183
|
+
raise ValueError("Value cannot be empty.")
|
|
184
|
+
return int(stripped)
|
|
185
|
+
|
|
186
|
+
raise TypeError("Value must be an int.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class ConfigFieldSpec:
|
|
191
|
+
prompt: str
|
|
192
|
+
default: object
|
|
193
|
+
normalize: Callable[[object], object]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
CONFIG_FIELD_SPECS: dict[str, ConfigFieldSpec] = {
|
|
197
|
+
"default_source": ConfigFieldSpec(
|
|
198
|
+
prompt="Default source directory",
|
|
199
|
+
default="",
|
|
200
|
+
normalize=normalize_optional_directory,
|
|
201
|
+
),
|
|
202
|
+
"default_destination": ConfigFieldSpec(
|
|
203
|
+
prompt="Default destination directory",
|
|
204
|
+
default="",
|
|
205
|
+
normalize=normalize_optional_directory,
|
|
206
|
+
),
|
|
207
|
+
"auto_stage_enabled": ConfigFieldSpec(
|
|
208
|
+
prompt="Auto-stage screenshots with `git add`?",
|
|
209
|
+
default=False,
|
|
210
|
+
normalize=normalize_bool,
|
|
211
|
+
),
|
|
212
|
+
"default_output_format": ConfigFieldSpec(
|
|
213
|
+
prompt="Default output style (markdown, html, text)",
|
|
214
|
+
default=DEFAULT_OUTPUT_FORMAT,
|
|
215
|
+
normalize=normalize_output_format,
|
|
216
|
+
),
|
|
217
|
+
"default_convert_to": ConfigFieldSpec(
|
|
218
|
+
prompt="Default conversion format (png, jpg/jpeg, webp, gif). Leave empty for none.",
|
|
219
|
+
default=None,
|
|
220
|
+
normalize=normalize_default_convert_to,
|
|
221
|
+
),
|
|
222
|
+
"max_file_size_mb": ConfigFieldSpec(
|
|
223
|
+
prompt="Per-file size limit in MB (max 50)",
|
|
224
|
+
default=MAX_IMAGE_FILE_SIZE_BYTES // (1024 * 1024),
|
|
225
|
+
normalize=normalize_int,
|
|
226
|
+
),
|
|
227
|
+
"max_total_size_mb": ConfigFieldSpec(
|
|
228
|
+
prompt="Max total size in MB per batch (max 200). Use <=0 for 200.",
|
|
229
|
+
default=MAX_TOTAL_IMAGE_SIZE_BYTES // (1024 * 1024),
|
|
230
|
+
normalize=normalize_int,
|
|
231
|
+
),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
DEFAULT_CONFIG: dict[str, object] = {
|
|
236
|
+
field: spec.default for field, spec in CONFIG_FIELD_SPECS.items()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_interactive_terminal() -> bool:
|
|
241
|
+
"""
|
|
242
|
+
Return True when user interaction (prompting) is expected to work.
|
|
243
|
+
|
|
244
|
+
We intentionally keep this conservative: when stdin is not a TTY, prompting for
|
|
245
|
+
config values will block CI/CD and scripted runs.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
return bool(getattr(sys.stdin, "isatty", lambda: False)())
|
|
249
|
+
except Exception:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _next_available_backup_path(path: Path, *, suffix: str) -> Path:
|
|
254
|
+
"""
|
|
255
|
+
Return an available path for a backup file next to `path`.
|
|
256
|
+
|
|
257
|
+
Example: `config.json` -> `config.json.corrupted`, then `.corrupted.1`, ...
|
|
258
|
+
"""
|
|
259
|
+
candidate = path.with_name(f"{path.name}{suffix}")
|
|
260
|
+
if not candidate.exists():
|
|
261
|
+
return candidate
|
|
262
|
+
|
|
263
|
+
for index in range(1, 1000):
|
|
264
|
+
candidate = path.with_name(f"{path.name}{suffix}.{index}")
|
|
265
|
+
if not candidate.exists():
|
|
266
|
+
return candidate
|
|
267
|
+
|
|
268
|
+
raise OSError(f"Too many backup files for {path.name}{suffix}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _backup_corrupted_file_or_warn(config_file_path: Path) -> None:
|
|
272
|
+
backup_path: Path | None = None
|
|
273
|
+
try:
|
|
274
|
+
config_data_path = resolve_config_data_path(config_file_path)
|
|
275
|
+
backup_path = _next_available_backup_path(config_data_path, suffix=".corrupted")
|
|
276
|
+
config_data_path.replace(backup_path)
|
|
277
|
+
except (OSError, SecurityError) as backup_error:
|
|
278
|
+
sanitized = sanitize_error_message(
|
|
279
|
+
str(backup_error),
|
|
280
|
+
(config_file_path, backup_path) if backup_path is not None else (config_file_path,),
|
|
281
|
+
)
|
|
282
|
+
click.echo(
|
|
283
|
+
f"{WARNING_PREFIX} Could not back up the corrupted config file: {sanitized}",
|
|
284
|
+
err=True,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def atomic_write_json(path: Path, data: dict, mode: int = CONFIG_FILE_PERMISSIONS) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Write JSON data atomically to prevent corruption.
|
|
291
|
+
|
|
292
|
+
The temp file is created in the same directory as the target file
|
|
293
|
+
to ensure atomic rename (same filesystem). On POSIX systems,
|
|
294
|
+
os.replace() is atomic.
|
|
295
|
+
|
|
296
|
+
Directory fsync is best-effort: if it fails after the atomic rename succeeds,
|
|
297
|
+
a warning is emitted but the function returns successfully. The config is
|
|
298
|
+
updated; only durability across power loss is not guaranteed.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
path: Path to target file
|
|
302
|
+
data: Dictionary to write as JSON
|
|
303
|
+
mode: File permissions (default CONFIG_FILE_PERMISSIONS)
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
OSError: If temp file creation or atomic rename fails.
|
|
307
|
+
TypeError/ValueError: If JSON encoding fails.
|
|
308
|
+
"""
|
|
309
|
+
# Create temp file in same directory (ensures same filesystem)
|
|
310
|
+
temp_fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}_", suffix=".tmp")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# Write to temp file
|
|
314
|
+
with os.fdopen(temp_fd, "w", encoding="UTF-8") as f:
|
|
315
|
+
json.dump(data, f, indent=4)
|
|
316
|
+
f.flush() # Flush Python buffers to OS
|
|
317
|
+
os.fsync(f.fileno()) # Force OS to write to physical disk
|
|
318
|
+
|
|
319
|
+
# Set permissions on temp file
|
|
320
|
+
os.chmod(temp_path, mode)
|
|
321
|
+
|
|
322
|
+
# Atomic rename (POSIX guarantees atomicity)
|
|
323
|
+
os.replace(temp_path, str(path))
|
|
324
|
+
|
|
325
|
+
except Exception:
|
|
326
|
+
# Cleanup temp file on any error before rename
|
|
327
|
+
try:
|
|
328
|
+
os.unlink(temp_path)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
# Best-effort directory fsync for durability
|
|
334
|
+
# Config is already updated; failure here only affects durability across power loss
|
|
335
|
+
try:
|
|
336
|
+
dir_flags = os.O_RDONLY
|
|
337
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
338
|
+
dir_flags |= os.O_DIRECTORY
|
|
339
|
+
dir_fd = os.open(path.parent, dir_flags)
|
|
340
|
+
try:
|
|
341
|
+
os.fsync(dir_fd)
|
|
342
|
+
finally:
|
|
343
|
+
os.close(dir_fd)
|
|
344
|
+
except OSError as error:
|
|
345
|
+
click.echo(
|
|
346
|
+
f"{WARNING_PREFIX} Config saved but durability not guaranteed: {error}",
|
|
347
|
+
err=True,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def write_config_safely(config_file_path: Path, config_data: dict[str, object]) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Write configuration data while enforcing secure permissions.
|
|
354
|
+
|
|
355
|
+
Attempts to fix insecure permissions on existing files (best-effort); if chmod fails,
|
|
356
|
+
the atomic write is still attempted since it creates a fresh file with correct
|
|
357
|
+
permissions.
|
|
358
|
+
|
|
359
|
+
Symlinked config files are supported for dotfile manager workflows (for example
|
|
360
|
+
GNU Stow). When the config path is a symlink, writes target the symlink target
|
|
361
|
+
path and preserve the symlink itself.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
config_file_path: Path to config file
|
|
365
|
+
config_data: Configuration dictionary to write
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
SecurityError: If the resolved config path is invalid
|
|
369
|
+
OSError: If the atomic write fails
|
|
370
|
+
"""
|
|
371
|
+
config_data_path = resolve_config_data_path(config_file_path)
|
|
372
|
+
|
|
373
|
+
if config_data_path.exists():
|
|
374
|
+
current_perms = config_data_path.stat().st_mode & FILE_PERMISSION_MASK
|
|
375
|
+
if current_perms != CONFIG_FILE_PERMISSIONS:
|
|
376
|
+
click.echo(
|
|
377
|
+
f"{WARNING_PREFIX} Config file permissions were too open ({oct(current_perms)}). "
|
|
378
|
+
f"Resetting to {oct(CONFIG_FILE_PERMISSIONS)}.",
|
|
379
|
+
err=True,
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
config_data_path.chmod(CONFIG_FILE_PERMISSIONS)
|
|
383
|
+
except OSError as error:
|
|
384
|
+
# Best-effort: warn but proceed with atomic write
|
|
385
|
+
# The atomic replace will create a new file with correct permissions
|
|
386
|
+
sanitized = sanitize_error_message(str(error), (config_data_path,))
|
|
387
|
+
click.echo(
|
|
388
|
+
f"{WARNING_PREFIX} Could not fix permissions ({sanitized}); "
|
|
389
|
+
"atomic write will replace with secure file.",
|
|
390
|
+
err=True,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
atomic_write_json(config_data_path, config_data, mode=CONFIG_FILE_PERMISSIONS)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def resolve_config_data_path(config_file_path: Path) -> Path:
|
|
397
|
+
"""
|
|
398
|
+
Resolve the effective config data path, following symlinks when present.
|
|
399
|
+
|
|
400
|
+
This keeps symlink-based dotfile layouts working while still rejecting invalid
|
|
401
|
+
targets like directories or symlink loops.
|
|
402
|
+
"""
|
|
403
|
+
if not config_file_path.is_symlink():
|
|
404
|
+
return config_file_path
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
resolved_path = config_file_path.resolve(strict=False)
|
|
408
|
+
except RuntimeError as error:
|
|
409
|
+
raise SecurityError("Config file symlink loop detected; refusing to use it.") from error
|
|
410
|
+
|
|
411
|
+
if resolved_path.exists() and resolved_path.is_dir():
|
|
412
|
+
raise SecurityError("Config file symlink target is a directory; refusing to use it.")
|
|
413
|
+
|
|
414
|
+
return resolved_path
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def write_config_or_exit(config_file_path: Path, config_data: dict[str, object]) -> None:
|
|
418
|
+
"""
|
|
419
|
+
Persist config changes and present user-friendly failures.
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
write_config_safely(config_file_path, config_data)
|
|
423
|
+
except (FileNotFoundError, SecurityError, OSError) as error:
|
|
424
|
+
sanitized_error = format_path_error(error)
|
|
425
|
+
click.secho(
|
|
426
|
+
f"Error: Failed to write config file: {sanitized_error}",
|
|
427
|
+
fg="red",
|
|
428
|
+
err=True,
|
|
429
|
+
)
|
|
430
|
+
sys.exit(1)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def resolve_path_safely(path_str: str, check_symlink: bool = True) -> Path:
|
|
434
|
+
"""
|
|
435
|
+
Safely resolve a path without following symlinks.
|
|
436
|
+
|
|
437
|
+
This function prevents symlink following attacks (CWE-59) by validating
|
|
438
|
+
that neither the target path nor any component in its parent chain is a
|
|
439
|
+
symlink. This protects against attackers creating symlinks to sensitive
|
|
440
|
+
files (SSH keys, credentials) and tricking the application into copying
|
|
441
|
+
them.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
path_str: The path to resolve (can include `~` for home directory)
|
|
445
|
+
check_symlink: If True, reject symlinks (default True for security)
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Resolved Path object (absolute path)
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ValueError: If path is a symlink and `check_symlink=True`
|
|
452
|
+
FileNotFoundError: If path doesn't exist
|
|
453
|
+
|
|
454
|
+
Example:
|
|
455
|
+
>>> resolve_path_safely("/home/user/screenshots")
|
|
456
|
+
PosixPath('/home/user/screenshots')
|
|
457
|
+
|
|
458
|
+
>>> resolve_path_safely("/tmp/symlink_to_ssh_key")
|
|
459
|
+
ValueError: Symlinks are not allowed: /tmp/symlink_to_ssh_key
|
|
460
|
+
"""
|
|
461
|
+
# Expand user home directory (~)
|
|
462
|
+
path = Path(path_str).expanduser()
|
|
463
|
+
|
|
464
|
+
# Check if the target path itself is a symlink before resolving
|
|
465
|
+
if check_symlink and path.is_symlink():
|
|
466
|
+
raise ValueError(f"Symlinks are not allowed: {path_str}")
|
|
467
|
+
|
|
468
|
+
# Validate no symlinks exist in the parent directory chain BEFORE resolving
|
|
469
|
+
# This prevents attacks like: /tmp/link -> /home/user/.ssh, then /tmp/link/id_rsa
|
|
470
|
+
if check_symlink:
|
|
471
|
+
# Check each component in the path hierarchy (before resolution)
|
|
472
|
+
# Start from the path and work up to root
|
|
473
|
+
current = path.absolute()
|
|
474
|
+
while current != current.parent:
|
|
475
|
+
if current.is_symlink():
|
|
476
|
+
raise ValueError(f"Path contains symlink in parent chain: {current}")
|
|
477
|
+
current = current.parent
|
|
478
|
+
|
|
479
|
+
# Resolve to absolute path (will follow symlinks if they exist)
|
|
480
|
+
# strict=True ensures the path exists
|
|
481
|
+
resolved = path.resolve(strict=True)
|
|
482
|
+
|
|
483
|
+
return resolved
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def create_directory_safely(
|
|
487
|
+
directory: Path, mode: int = 0o755, *, harden_permissions: bool = True
|
|
488
|
+
) -> Path:
|
|
489
|
+
"""
|
|
490
|
+
Create directory with TOCTOU protection.
|
|
491
|
+
|
|
492
|
+
Creates parent directories iteratively with validation between each step
|
|
493
|
+
to prevent TOCTOU race conditions. Verifies directories are not symlinks,
|
|
494
|
+
owned by current user, and have appropriate permissions.
|
|
495
|
+
|
|
496
|
+
Permission Policy:
|
|
497
|
+
When `harden_permissions=True`, this function prevents *insecure*
|
|
498
|
+
permissions (group/other writable, i.e., 0o022 bits set) but does not
|
|
499
|
+
enforce the exact `mode`. For example, an existing 0o755 directory will
|
|
500
|
+
not be tightened to 0o700 since 0o755 is already secure.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
directory: Directory path to create
|
|
504
|
+
mode: Permission mode for new directories (default 0o755)
|
|
505
|
+
harden_permissions: If True (default), fix group/other writable
|
|
506
|
+
permissions on the target directory. Set to False for shared
|
|
507
|
+
directories like git-tracked image folders where group-write
|
|
508
|
+
may be intentional.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
The created or verified directory path
|
|
512
|
+
|
|
513
|
+
Raises:
|
|
514
|
+
SecurityError: If symlink detected or ownership mismatch
|
|
515
|
+
|
|
516
|
+
Note:
|
|
517
|
+
On non-POSIX systems (e.g., Windows), ownership validation is skipped
|
|
518
|
+
since `os.getuid()` is not available.
|
|
519
|
+
"""
|
|
520
|
+
# Get absolute path
|
|
521
|
+
directory = directory.absolute()
|
|
522
|
+
|
|
523
|
+
# Build list of all path components from root to target
|
|
524
|
+
# We need to validate from shallowest to deepest to prevent TOCTOU
|
|
525
|
+
components = []
|
|
526
|
+
current = directory
|
|
527
|
+
while current != current.parent:
|
|
528
|
+
components.append(current)
|
|
529
|
+
current = current.parent
|
|
530
|
+
components.reverse() # Now ordered from root to target
|
|
531
|
+
|
|
532
|
+
# Track which directories we create (vs already existed)
|
|
533
|
+
created_dirs = set()
|
|
534
|
+
|
|
535
|
+
# Check if ownership validation is available (POSIX-only)
|
|
536
|
+
# On Windows, os.getuid() doesn't exist; skip ownership checks there
|
|
537
|
+
can_check_ownership = hasattr(os, "getuid")
|
|
538
|
+
|
|
539
|
+
# Create directories one-by-one with validation between each
|
|
540
|
+
# This prevents TOCTOU attacks during mkdir(parents=True)
|
|
541
|
+
for idx, component in enumerate(components):
|
|
542
|
+
# Use lstat to check existence without following symlinks
|
|
543
|
+
# Also detect existing symlinks in the same syscall
|
|
544
|
+
try:
|
|
545
|
+
pre_stat = component.lstat()
|
|
546
|
+
existed_before = True
|
|
547
|
+
# Pre-creation check: detect existing symlinks
|
|
548
|
+
if S_ISLNK(pre_stat.st_mode):
|
|
549
|
+
raise SecurityError(f"Path contains symlink: {sanitize_path_for_error(component)}")
|
|
550
|
+
except FileNotFoundError:
|
|
551
|
+
existed_before = False
|
|
552
|
+
|
|
553
|
+
if not existed_before:
|
|
554
|
+
try:
|
|
555
|
+
component.mkdir(mode=mode, exist_ok=False)
|
|
556
|
+
created_dirs.add(component)
|
|
557
|
+
except FileExistsError:
|
|
558
|
+
# Race condition: directory created between lstat() and mkdir()
|
|
559
|
+
# Fall through to post-creation validation
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
# Post-creation validation using lstat (does not follow symlinks)
|
|
563
|
+
# This is the critical security check that replaces is_symlink() + is_dir()
|
|
564
|
+
try:
|
|
565
|
+
stat_info = component.lstat()
|
|
566
|
+
except FileNotFoundError as err:
|
|
567
|
+
raise SecurityError(
|
|
568
|
+
f"Path disappeared during creation: {sanitize_path_for_error(component)}"
|
|
569
|
+
) from err
|
|
570
|
+
|
|
571
|
+
# Check if it's a symlink using the already-fetched stat_info
|
|
572
|
+
# This avoids a TOCTOU race between lstat() and a separate is_symlink() call
|
|
573
|
+
if S_ISLNK(stat_info.st_mode):
|
|
574
|
+
raise SecurityError(f"Path is a symlink: {sanitize_path_for_error(component)}")
|
|
575
|
+
|
|
576
|
+
# Use S_ISDIR on lstat result to verify it's a directory without following symlinks
|
|
577
|
+
if not S_ISDIR(stat_info.st_mode):
|
|
578
|
+
raise SecurityError(
|
|
579
|
+
f"Path exists but is not a directory: {sanitize_path_for_error(component)}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Re-validate all parent components to catch TOCTOU attacks
|
|
583
|
+
# An attacker might replace an earlier parent with a symlink
|
|
584
|
+
for parent_idx in range(idx):
|
|
585
|
+
parent = components[parent_idx]
|
|
586
|
+
try:
|
|
587
|
+
parent_stat = parent.lstat()
|
|
588
|
+
if S_ISLNK(parent_stat.st_mode):
|
|
589
|
+
raise SecurityError(
|
|
590
|
+
f"Parent path became symlink: {sanitize_path_for_error(parent)}"
|
|
591
|
+
)
|
|
592
|
+
except FileNotFoundError as err:
|
|
593
|
+
raise SecurityError(
|
|
594
|
+
f"Parent path disappeared: {sanitize_path_for_error(parent)}"
|
|
595
|
+
) from err
|
|
596
|
+
|
|
597
|
+
# Perform ownership validation for directories we created or the final target
|
|
598
|
+
# Skip ownership check for pre-existing system directories (e.g., /tmp, /home)
|
|
599
|
+
if can_check_ownership and (component in created_dirs or component == directory):
|
|
600
|
+
if stat_info.st_uid != os.getuid():
|
|
601
|
+
raise SecurityError(
|
|
602
|
+
f"Directory owned by different user (UID {stat_info.st_uid}): "
|
|
603
|
+
f"{sanitize_path_for_error(component)}"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# For the final target directory only, optionally fix unsafe permissions
|
|
607
|
+
if harden_permissions and component == directory:
|
|
608
|
+
current_mode = stat_info.st_mode & FILE_PERMISSION_MASK
|
|
609
|
+
if current_mode & 0o022:
|
|
610
|
+
click.echo(
|
|
611
|
+
f"{WARNING_PREFIX} Directory has unsafe permissions ({oct(current_mode)}). "
|
|
612
|
+
f"Fixing to {oct(mode)}.",
|
|
613
|
+
err=True,
|
|
614
|
+
)
|
|
615
|
+
# Re-check symlink before chmod to close TOCTOU window
|
|
616
|
+
# Use lstat + S_ISLNK for consistency with other checks
|
|
617
|
+
try:
|
|
618
|
+
pre_chmod_stat = directory.lstat()
|
|
619
|
+
if S_ISLNK(pre_chmod_stat.st_mode):
|
|
620
|
+
raise SecurityError(
|
|
621
|
+
f"Path became symlink before chmod: {sanitize_path_for_error(directory)}"
|
|
622
|
+
)
|
|
623
|
+
except FileNotFoundError as err:
|
|
624
|
+
raise SecurityError(
|
|
625
|
+
f"Path disappeared before chmod: {sanitize_path_for_error(directory)}"
|
|
626
|
+
) from err
|
|
627
|
+
try:
|
|
628
|
+
# Use follow_symlinks=False to prevent symlink dereferencing
|
|
629
|
+
directory.chmod(mode, follow_symlinks=False)
|
|
630
|
+
except NotImplementedError as err:
|
|
631
|
+
# On Linux, chmod with follow_symlinks=False fails on symlinks.
|
|
632
|
+
# This indicates a TOCTOU race: path became a symlink after our check.
|
|
633
|
+
raise SecurityError(
|
|
634
|
+
f"Path became symlink during chmod: {sanitize_path_for_error(directory)}"
|
|
635
|
+
) from err
|
|
636
|
+
except OSError as error:
|
|
637
|
+
# Best-effort: warn but proceed since ownership check passed
|
|
638
|
+
sanitized = sanitize_error_message(str(error), (directory,))
|
|
639
|
+
click.echo(
|
|
640
|
+
f"{WARNING_PREFIX} Could not fix directory permissions: {sanitized}",
|
|
641
|
+
err=True,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
return directory
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def sanitize_path_for_error(path: str | Path, *, show_basename: bool = True) -> str:
|
|
648
|
+
"""
|
|
649
|
+
Sanitize filesystem paths in error messages (CWE-209 prevention).
|
|
650
|
+
|
|
651
|
+
This function prevents CWE-209 (Information Exposure Through Error Message) by
|
|
652
|
+
hiding sensitive path information that could reveal usernames, directory structure,
|
|
653
|
+
or system configuration to attackers.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
path: Path to sanitize (string or Path object)
|
|
657
|
+
show_basename: If True, show `<...>/filename`; if False, show `<path>`
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Sanitized path string safe for error messages
|
|
661
|
+
|
|
662
|
+
Examples:
|
|
663
|
+
>>> sanitize_path_for_error("/home/alice/.ssh/key.txt")
|
|
664
|
+
'<...>/key.txt'
|
|
665
|
+
|
|
666
|
+
>>> sanitize_path_for_error("/home/alice/.ssh/key.txt", show_basename=False)
|
|
667
|
+
'<path>'
|
|
668
|
+
|
|
669
|
+
Security Context:
|
|
670
|
+
Without sanitization, error messages like "Source directory /home/alice_admin/.secret
|
|
671
|
+
does not exist" reveal usernames and directory structure to attackers probing the system.
|
|
672
|
+
"""
|
|
673
|
+
if isinstance(path, Path):
|
|
674
|
+
path = str(path)
|
|
675
|
+
|
|
676
|
+
if not show_basename:
|
|
677
|
+
return "<path>"
|
|
678
|
+
|
|
679
|
+
path_str = str(path)
|
|
680
|
+
if not path_str:
|
|
681
|
+
return "<path>"
|
|
682
|
+
|
|
683
|
+
# Normalize both POSIX and Windows separators to safely extract basename
|
|
684
|
+
normalized = path_str.replace("\\", "/").rstrip("/")
|
|
685
|
+
basename = normalized.split("/")[-1] if normalized else ""
|
|
686
|
+
|
|
687
|
+
if not basename or basename == ".":
|
|
688
|
+
return "<path>"
|
|
689
|
+
|
|
690
|
+
return f"<...>/{basename}"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def format_path_error(error: Exception, *, show_basename: bool = True) -> str:
|
|
694
|
+
"""
|
|
695
|
+
Format path-related errors with sanitized paths for safe display.
|
|
696
|
+
|
|
697
|
+
Keeps user-facing context like "No such file or directory" while ensuring
|
|
698
|
+
filesystem paths are redacted to prevent CWE-209 information disclosure.
|
|
699
|
+
"""
|
|
700
|
+
if isinstance(error, FileNotFoundError):
|
|
701
|
+
filename = error.filename or error.filename2
|
|
702
|
+
reason = error.strerror or "Path not found"
|
|
703
|
+
if filename:
|
|
704
|
+
sanitized = sanitize_path_for_error(filename, show_basename=show_basename)
|
|
705
|
+
return f"{reason}: {sanitized}"
|
|
706
|
+
return reason
|
|
707
|
+
|
|
708
|
+
message = str(error)
|
|
709
|
+
if ": " in message:
|
|
710
|
+
prefix, path_part = message.split(": ", 1)
|
|
711
|
+
# Only sanitize when the suffix looks like a filesystem path
|
|
712
|
+
if any(sep in path_part for sep in ("/", "\\")):
|
|
713
|
+
sanitized = sanitize_path_for_error(path_part, show_basename=show_basename)
|
|
714
|
+
return f"{prefix}: {sanitized}"
|
|
715
|
+
|
|
716
|
+
return message
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def sanitize_error_message(
|
|
720
|
+
message: str,
|
|
721
|
+
paths: tuple[str | Path, ...],
|
|
722
|
+
*,
|
|
723
|
+
show_basename: bool = True,
|
|
724
|
+
) -> str:
|
|
725
|
+
"""
|
|
726
|
+
Replace occurrences of filesystem paths inside an error message with sanitized versions.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
message: Error message potentially containing sensitive paths.
|
|
730
|
+
paths: Tuple of paths to sanitize if present in the message.
|
|
731
|
+
show_basename: Whether to reveal the basename when sanitizing.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
Message with sensitive paths redacted.
|
|
735
|
+
"""
|
|
736
|
+
sanitized_message = message
|
|
737
|
+
for path in paths:
|
|
738
|
+
sanitized_message = sanitized_message.replace(
|
|
739
|
+
str(path),
|
|
740
|
+
sanitize_path_for_error(path, show_basename=show_basename),
|
|
741
|
+
)
|
|
742
|
+
return sanitized_message
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def validate_image_file(
|
|
746
|
+
file_path: Path,
|
|
747
|
+
*,
|
|
748
|
+
max_size_bytes: int | None = None,
|
|
749
|
+
file_size: int | None = None,
|
|
750
|
+
) -> bool:
|
|
751
|
+
"""
|
|
752
|
+
Validate file is actually an image by checking magic bytes.
|
|
753
|
+
|
|
754
|
+
This function prevents file content validation attacks (CWE-434) by
|
|
755
|
+
verifying that files are legitimate images, not malicious scripts or
|
|
756
|
+
executables renamed with image extensions.
|
|
757
|
+
|
|
758
|
+
Uses Pillow's `Image.verify()` to check magic bytes and file structure.
|
|
759
|
+
Also enforces a 50MB per-file size limit to prevent DoS attacks.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
file_path: Path to file to validate
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
bool: True if valid image file
|
|
766
|
+
|
|
767
|
+
Raises:
|
|
768
|
+
ValueError: If file is not a valid image or exceeds size limit
|
|
769
|
+
|
|
770
|
+
Example:
|
|
771
|
+
>>> validate_image_file(Path("/tmp/screenshot.png"))
|
|
772
|
+
True
|
|
773
|
+
|
|
774
|
+
>>> validate_image_file(Path("/tmp/malicious.png")) # Actually a script
|
|
775
|
+
ValueError: File is not a valid image: malicious.png
|
|
776
|
+
The size check can be overridden for testing or configuration. Passing
|
|
777
|
+
`file_size` avoids re-statting the file when the caller already has that
|
|
778
|
+
information (e.g., during directory scans).
|
|
779
|
+
"""
|
|
780
|
+
# Enforce per-file size limit to prevent DoS attacks
|
|
781
|
+
max_size = MAX_IMAGE_FILE_SIZE_BYTES if max_size_bytes is None else max_size_bytes
|
|
782
|
+
try:
|
|
783
|
+
size_value = file_size if file_size is not None else file_path.stat().st_size
|
|
784
|
+
except OSError as e:
|
|
785
|
+
raise ValueError(f"Cannot read file: {file_path.name}") from e
|
|
786
|
+
|
|
787
|
+
if max_size is not None and size_value > max_size:
|
|
788
|
+
raise ValueError(
|
|
789
|
+
f"File too large: {file_path.name} ({size_value / 1024 / 1024:.2f}MB; "
|
|
790
|
+
f"max {max_size / 1024 / 1024:.0f}MB)"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Validate magic bytes using Pillow
|
|
794
|
+
try:
|
|
795
|
+
# Configure Pillow to treat decompression bomb warnings as errors
|
|
796
|
+
# This prevents oversized images (89M+ pixels) from bypassing validation
|
|
797
|
+
warnings.filterwarnings("error", category=Image.DecompressionBombWarning)
|
|
798
|
+
|
|
799
|
+
with Image.open(file_path) as img:
|
|
800
|
+
# Read format BEFORE calling verify() - verify() invalidates the image object
|
|
801
|
+
img_format = img.format
|
|
802
|
+
|
|
803
|
+
# Check format is supported (PNG, JPEG, GIF)
|
|
804
|
+
if img_format not in ("PNG", "JPEG", "GIF"):
|
|
805
|
+
raise ValueError(
|
|
806
|
+
f"Unsupported image format: {img_format} (supported: PNG, JPEG, GIF)"
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
img.verify() # Validates magic bytes and basic file structure
|
|
810
|
+
|
|
811
|
+
# Re-open image to check dimensions (verify() invalidates the object)
|
|
812
|
+
with Image.open(file_path) as img_check:
|
|
813
|
+
total_pixels = img_check.width * img_check.height
|
|
814
|
+
if total_pixels > MAX_IMAGE_PIXELS:
|
|
815
|
+
raise ValueError(
|
|
816
|
+
f"Image dimensions too large: {img_check.width}x{img_check.height} "
|
|
817
|
+
f"({total_pixels:,} pixels, maximum: {MAX_IMAGE_PIXELS:,})"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# Reject files with trailing payloads after the format trailer
|
|
821
|
+
file_bytes = file_path.read_bytes()
|
|
822
|
+
if img_format == "PNG" and not file_bytes.endswith(PNG_TRAILER):
|
|
823
|
+
raise ValueError(f"File contains trailing data after PNG trailer: {file_path.name}")
|
|
824
|
+
if img_format == "JPEG" and not file_bytes.endswith(JPEG_TRAILER):
|
|
825
|
+
raise ValueError(f"File contains trailing data after JPEG trailer: {file_path.name}")
|
|
826
|
+
if img_format == "GIF" and not file_bytes.endswith(GIF_TRAILER):
|
|
827
|
+
raise ValueError(f"File contains trailing data after GIF trailer: {file_path.name}")
|
|
828
|
+
|
|
829
|
+
return True
|
|
830
|
+
|
|
831
|
+
except (Image.DecompressionBombError, Image.DecompressionBombWarning) as e:
|
|
832
|
+
# Decompression bombs are images with huge dimensions but small file size
|
|
833
|
+
# (e.g., 1MB file that decompresses to 10GB). Pillow's default limit is
|
|
834
|
+
# 89,478,485 pixels (~178MB at 24-bit color). We catch this separately
|
|
835
|
+
# to provide a clear error message.
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"Image dimensions too large: {file_path.name} "
|
|
838
|
+
f"(exceeds {MAX_IMAGE_PIXELS:,} pixel limit, suspected decompression bomb attack)"
|
|
839
|
+
) from e
|
|
840
|
+
except (OSError, Image.UnidentifiedImageError) as e:
|
|
841
|
+
raise ValueError(f"File is not a valid image: {file_path.name}") from e
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def get_size_limits(config: dict[str, object]) -> tuple[int, int | None]:
|
|
845
|
+
"""
|
|
846
|
+
Resolve per-file and aggregate size limits from config (in MB).
|
|
847
|
+
|
|
848
|
+
Config values are clamped to hard security ceilings (HARD_MAX_*) to prevent
|
|
849
|
+
DoS attacks. Users can set lower limits, but cannot exceed hard maximums.
|
|
850
|
+
|
|
851
|
+
A non-positive aggregate limit in config now applies the hard ceiling
|
|
852
|
+
(HARD_MAX_TOTAL_SIZE_BYTES) instead of disabling the check entirely.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
Tuple of (file_limit_bytes, total_limit_bytes)
|
|
856
|
+
- file_limit_bytes: Per-file limit (always enforced, max 50MB)
|
|
857
|
+
- total_limit_bytes: Aggregate limit (max 200MB, never None)
|
|
858
|
+
"""
|
|
859
|
+
default_file_limit_mb = MAX_IMAGE_FILE_SIZE_BYTES // (1024 * 1024)
|
|
860
|
+
default_total_limit_mb = MAX_TOTAL_IMAGE_SIZE_BYTES // (1024 * 1024)
|
|
861
|
+
|
|
862
|
+
file_limit_mb = config.get("max_file_size_mb", default_file_limit_mb)
|
|
863
|
+
total_limit_mb = config.get("max_total_size_mb", default_total_limit_mb)
|
|
864
|
+
|
|
865
|
+
# Calculate requested file limit
|
|
866
|
+
file_limit_bytes = MAX_IMAGE_FILE_SIZE_BYTES
|
|
867
|
+
if isinstance(file_limit_mb, (int, float)) and file_limit_mb > 0:
|
|
868
|
+
file_limit_bytes = int(file_limit_mb * 1024 * 1024)
|
|
869
|
+
|
|
870
|
+
# Enforce hard ceiling on per-file limit
|
|
871
|
+
file_limit_bytes = min(file_limit_bytes, HARD_MAX_FILE_SIZE_BYTES)
|
|
872
|
+
|
|
873
|
+
# Calculate aggregate limit
|
|
874
|
+
total_limit_bytes: int | None = MAX_TOTAL_IMAGE_SIZE_BYTES
|
|
875
|
+
if isinstance(total_limit_mb, (int, float)):
|
|
876
|
+
if total_limit_mb > 0:
|
|
877
|
+
total_limit_bytes = int(total_limit_mb * 1024 * 1024)
|
|
878
|
+
# Enforce hard ceiling on aggregate limit
|
|
879
|
+
total_limit_bytes = min(total_limit_bytes, HARD_MAX_TOTAL_SIZE_BYTES)
|
|
880
|
+
else:
|
|
881
|
+
# User disabled aggregate limit, but hard ceiling still applies
|
|
882
|
+
total_limit_bytes = HARD_MAX_TOTAL_SIZE_BYTES
|
|
883
|
+
|
|
884
|
+
return file_limit_bytes, total_limit_bytes
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def suggest_format(invalid_format: str, valid_formats: list[str]) -> str:
|
|
888
|
+
"""Suggest a similar format if user provides invalid input."""
|
|
889
|
+
invalid_lower = invalid_format.lower()
|
|
890
|
+
|
|
891
|
+
# Simple similarity check
|
|
892
|
+
suggestions = []
|
|
893
|
+
for fmt in valid_formats:
|
|
894
|
+
if invalid_lower in fmt or fmt in invalid_lower:
|
|
895
|
+
suggestions.append(fmt)
|
|
896
|
+
elif len(invalid_lower) > 2 and any(
|
|
897
|
+
invalid_lower[i : i + 2] in fmt for i in range(len(invalid_lower) - 1)
|
|
898
|
+
):
|
|
899
|
+
suggestions.append(fmt)
|
|
900
|
+
|
|
901
|
+
if suggestions:
|
|
902
|
+
return f"Did you mean: {', '.join(suggestions)}?"
|
|
903
|
+
return ""
|
|
30
904
|
|
|
31
905
|
|
|
32
906
|
@click.group(cls=DefaultGroup, default="fetch", default_if_no_args=True)
|
|
33
907
|
@click.version_option(package_name="wslshot")
|
|
34
908
|
def wslshot():
|
|
35
909
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
910
|
+
Copy screenshots and print their copied paths (defaults to `fetch`).
|
|
911
|
+
|
|
912
|
+
\b
|
|
913
|
+
Examples:
|
|
914
|
+
wslshot
|
|
915
|
+
wslshot --count 3
|
|
916
|
+
wslshot "<...>/screenshot.png"
|
|
917
|
+
wslshot configure
|
|
43
918
|
"""
|
|
44
919
|
|
|
45
920
|
|
|
46
921
|
@wslshot.command()
|
|
47
|
-
@click.option(
|
|
48
|
-
"--source", "-s", help="Specify a custom source directory for this operation."
|
|
49
|
-
)
|
|
922
|
+
@click.option("--source", "-s", help="Source directory for this run (overrides config).")
|
|
50
923
|
@click.option(
|
|
51
924
|
"--destination",
|
|
52
925
|
"-d",
|
|
53
|
-
help="
|
|
926
|
+
help="Destination directory for this run (overrides config).",
|
|
54
927
|
)
|
|
55
928
|
@click.option(
|
|
56
929
|
"--count",
|
|
57
930
|
"-n",
|
|
58
931
|
default=1,
|
|
59
|
-
|
|
932
|
+
type=click.IntRange(min=1),
|
|
933
|
+
help="How many screenshots to copy (newest first). Default: 1.",
|
|
60
934
|
)
|
|
61
935
|
@click.option(
|
|
62
|
-
"--output-
|
|
63
|
-
"
|
|
64
|
-
help=(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
936
|
+
"--output-style",
|
|
937
|
+
"output_format",
|
|
938
|
+
help=(f"Output style for printed paths ({OUTPUT_FORMATS_HELP}; overrides config)."),
|
|
939
|
+
)
|
|
940
|
+
@click.option(
|
|
941
|
+
"--convert-to",
|
|
942
|
+
"-c",
|
|
943
|
+
type=click.Choice(list(VALID_CONVERT_FORMATS), case_sensitive=False),
|
|
944
|
+
help="Convert copied screenshots to this format (png, jpg/jpeg, webp, gif).",
|
|
945
|
+
)
|
|
946
|
+
@click.option(
|
|
947
|
+
"--optimize",
|
|
948
|
+
is_flag=True,
|
|
949
|
+
default=False,
|
|
950
|
+
help="Optimize copied screenshots in place (writes destination files; never source files).",
|
|
951
|
+
)
|
|
952
|
+
@click.option(
|
|
953
|
+
"--allow-symlinks",
|
|
954
|
+
is_flag=True,
|
|
955
|
+
default=False,
|
|
956
|
+
help="Allow symlinks in paths (security risk; use only with trusted paths).",
|
|
957
|
+
)
|
|
958
|
+
@click.option(
|
|
959
|
+
"--no-transfer",
|
|
960
|
+
is_flag=True,
|
|
961
|
+
default=False,
|
|
962
|
+
help="Print source paths without copying files (defaults to text output; no Git integration).",
|
|
68
963
|
)
|
|
69
964
|
@click.argument("image_path", type=click.Path(exists=True), required=False)
|
|
70
|
-
def fetch(
|
|
965
|
+
def fetch(
|
|
966
|
+
source,
|
|
967
|
+
destination,
|
|
968
|
+
count,
|
|
969
|
+
output_format,
|
|
970
|
+
convert_to,
|
|
971
|
+
optimize,
|
|
972
|
+
allow_symlinks,
|
|
973
|
+
no_transfer,
|
|
974
|
+
image_path,
|
|
975
|
+
):
|
|
976
|
+
"""
|
|
977
|
+
Copy screenshots into the destination directory and print their copied paths.
|
|
978
|
+
|
|
979
|
+
\b
|
|
980
|
+
Examples:
|
|
981
|
+
wslshot fetch
|
|
982
|
+
wslshot fetch --count 5
|
|
983
|
+
wslshot fetch --convert-to webp
|
|
984
|
+
wslshot fetch --optimize
|
|
985
|
+
wslshot fetch --no-transfer
|
|
986
|
+
wslshot fetch "<...>/screenshot.png"
|
|
71
987
|
"""
|
|
72
|
-
|
|
988
|
+
# --no-transfer conflict checks
|
|
989
|
+
if no_transfer:
|
|
990
|
+
if convert_to:
|
|
991
|
+
raise click.BadOptionUsage(
|
|
992
|
+
"--convert-to",
|
|
993
|
+
"requires file transfer; cannot combine with --no-transfer",
|
|
994
|
+
)
|
|
995
|
+
if optimize:
|
|
996
|
+
raise click.BadOptionUsage(
|
|
997
|
+
"--optimize",
|
|
998
|
+
"requires file transfer; cannot combine with --no-transfer",
|
|
999
|
+
)
|
|
1000
|
+
if destination:
|
|
1001
|
+
raise click.BadOptionUsage(
|
|
1002
|
+
"--destination",
|
|
1003
|
+
"--destination cannot be used with --no-transfer. Remove --destination or "
|
|
1004
|
+
"omit --no-transfer.",
|
|
1005
|
+
)
|
|
1006
|
+
if convert_to and optimize:
|
|
1007
|
+
raise click.BadOptionUsage(
|
|
1008
|
+
"--optimize",
|
|
1009
|
+
"cannot combine with --convert-to",
|
|
1010
|
+
)
|
|
73
1011
|
|
|
74
|
-
|
|
1012
|
+
skip_fields = {"default_source"} if image_path else None
|
|
75
1013
|
|
|
76
|
-
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1014
|
+
# When --no-transfer is set, avoid creating config file (read-only operation)
|
|
1015
|
+
if no_transfer:
|
|
1016
|
+
config_path = get_config_file_path_or_exit(create_if_missing=False)
|
|
1017
|
+
if config_path.exists():
|
|
1018
|
+
config = read_config_readonly(config_path, skip_fields=skip_fields)
|
|
1019
|
+
else:
|
|
1020
|
+
config = DEFAULT_CONFIG.copy()
|
|
1021
|
+
else:
|
|
1022
|
+
config = read_config(get_config_file_path_or_exit(), skip_fields=skip_fields)
|
|
1023
|
+
max_file_size_bytes, max_total_size_bytes = get_size_limits(config)
|
|
82
1024
|
|
|
83
1025
|
# Source directory
|
|
84
1026
|
if source is None:
|
|
85
1027
|
source = config["default_source"]
|
|
86
1028
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1029
|
+
if not image_path:
|
|
1030
|
+
try:
|
|
1031
|
+
source = resolve_path_safely(source, check_symlink=not allow_symlinks)
|
|
1032
|
+
except ValueError as error:
|
|
1033
|
+
sanitized_error = format_path_error(error)
|
|
1034
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {sanitized_error}", err=True)
|
|
1035
|
+
click.echo("Hint: If you trust this path, rerun with `--allow-symlinks`.", err=True)
|
|
1036
|
+
sys.exit(1)
|
|
1037
|
+
except FileNotFoundError:
|
|
1038
|
+
click.secho(
|
|
1039
|
+
f"Error: Source directory not found: {sanitize_path_for_error(source)}",
|
|
1040
|
+
fg="red",
|
|
1041
|
+
err=True,
|
|
1042
|
+
)
|
|
1043
|
+
click.echo("Hint: Set `--source` or run `wslshot configure`.", err=True)
|
|
1044
|
+
sys.exit(1)
|
|
1045
|
+
|
|
1046
|
+
# --no-transfer: validate and print source paths, then exit early
|
|
1047
|
+
if no_transfer:
|
|
1048
|
+
# Default to text output for scripting use cases (override config default)
|
|
1049
|
+
if output_format is None:
|
|
1050
|
+
output_format = OUTPUT_FORMAT_TEXT
|
|
1051
|
+
|
|
1052
|
+
if output_format.casefold() not in VALID_OUTPUT_FORMATS:
|
|
1053
|
+
click.secho(f"Error: Invalid `--output-style`: {output_format}", fg="red", err=True)
|
|
1054
|
+
valid_options = ", ".join(VALID_OUTPUT_FORMATS)
|
|
1055
|
+
suggestion = suggest_format(output_format, list(VALID_OUTPUT_FORMATS))
|
|
1056
|
+
hint = f"Hint: Use one of: {valid_options}."
|
|
1057
|
+
if suggestion:
|
|
1058
|
+
hint = f"{hint} {suggestion}"
|
|
1059
|
+
click.echo(hint, err=True)
|
|
1060
|
+
sys.exit(1)
|
|
1061
|
+
|
|
1062
|
+
if image_path:
|
|
1063
|
+
# Validate explicit image path
|
|
1064
|
+
try:
|
|
1065
|
+
image_path_resolved = resolve_path_safely(
|
|
1066
|
+
image_path, check_symlink=not allow_symlinks
|
|
1067
|
+
)
|
|
1068
|
+
validate_image_file(image_path_resolved, max_size_bytes=max_file_size_bytes)
|
|
1069
|
+
except ValueError as error:
|
|
1070
|
+
sanitized_error = format_path_error(error)
|
|
1071
|
+
error_msg = str(error).casefold()
|
|
1072
|
+
if "symlink" in error_msg:
|
|
1073
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {sanitized_error}", err=True)
|
|
1074
|
+
if not allow_symlinks:
|
|
1075
|
+
click.echo(
|
|
1076
|
+
"Hint: If you trust this path, rerun with `--allow-symlinks`.",
|
|
1077
|
+
err=True,
|
|
1078
|
+
)
|
|
1079
|
+
else:
|
|
1080
|
+
click.secho(f"Error: {sanitized_error}", fg="red", err=True)
|
|
1081
|
+
click.echo(f"Source file: {sanitize_path_for_error(image_path)}", err=True)
|
|
1082
|
+
sys.exit(1)
|
|
1083
|
+
except FileNotFoundError:
|
|
1084
|
+
click.secho(
|
|
1085
|
+
f"Error: Image file not found: {sanitize_path_for_error(image_path)}",
|
|
1086
|
+
fg="red",
|
|
1087
|
+
err=True,
|
|
1088
|
+
)
|
|
1089
|
+
click.echo("Hint: Check the path and try again.", err=True)
|
|
1090
|
+
sys.exit(1)
|
|
1091
|
+
|
|
1092
|
+
print_formatted_path(output_format, (image_path_resolved,), relative_to_repo=False)
|
|
1093
|
+
else:
|
|
1094
|
+
# Get screenshots from source directory (skips aggregate size limit)
|
|
1095
|
+
try:
|
|
1096
|
+
screenshots = get_screenshots(
|
|
1097
|
+
source,
|
|
1098
|
+
count,
|
|
1099
|
+
max_file_size_bytes=max_file_size_bytes,
|
|
1100
|
+
allow_symlinks=allow_symlinks,
|
|
1101
|
+
)
|
|
1102
|
+
except ScreenshotNotFoundError as error:
|
|
1103
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1104
|
+
click.echo("Hint: Set `--source` or run `wslshot configure`.", err=True)
|
|
1105
|
+
sys.exit(1)
|
|
1106
|
+
|
|
1107
|
+
print_formatted_path(output_format, screenshots, relative_to_repo=False)
|
|
1108
|
+
|
|
1109
|
+
return
|
|
92
1110
|
|
|
93
1111
|
# Destination directory
|
|
94
1112
|
if destination is None:
|
|
95
|
-
|
|
1113
|
+
try:
|
|
1114
|
+
destination = get_destination()
|
|
1115
|
+
except GitError as error:
|
|
1116
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1117
|
+
sys.exit(1)
|
|
1118
|
+
except SecurityError as error:
|
|
1119
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {error}", err=True)
|
|
1120
|
+
error_msg = str(error).lower()
|
|
1121
|
+
if "symlink" in error_msg:
|
|
1122
|
+
click.echo("Hint: Remove the symlink and try again.", err=True)
|
|
1123
|
+
elif "different user" in error_msg:
|
|
1124
|
+
click.echo("Hint: Check directory ownership or use a different path.", err=True)
|
|
1125
|
+
sys.exit(1)
|
|
96
1126
|
|
|
97
1127
|
try:
|
|
98
|
-
destination =
|
|
1128
|
+
destination = resolve_path_safely(destination, check_symlink=not allow_symlinks)
|
|
1129
|
+
except ValueError as error:
|
|
1130
|
+
sanitized_error = format_path_error(error)
|
|
1131
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {sanitized_error}", err=True)
|
|
1132
|
+
click.echo("Hint: If you trust this path, rerun with `--allow-symlinks`.", err=True)
|
|
1133
|
+
sys.exit(1)
|
|
99
1134
|
except FileNotFoundError:
|
|
100
|
-
|
|
1135
|
+
click.secho(
|
|
1136
|
+
f"Error: Destination directory not found: {sanitize_path_for_error(destination)}",
|
|
1137
|
+
fg="red",
|
|
1138
|
+
err=True,
|
|
1139
|
+
)
|
|
1140
|
+
click.echo("Hint: Set `--destination` or run `wslshot configure`.", err=True)
|
|
101
1141
|
sys.exit(1)
|
|
102
1142
|
|
|
103
1143
|
# Output format
|
|
104
1144
|
if output_format is None:
|
|
105
1145
|
output_format = config["default_output_format"]
|
|
106
1146
|
|
|
107
|
-
if output_format.casefold() not in
|
|
108
|
-
|
|
109
|
-
|
|
1147
|
+
if output_format.casefold() not in VALID_OUTPUT_FORMATS:
|
|
1148
|
+
click.secho(f"Error: Invalid `--output-style`: {output_format}", fg="red", err=True)
|
|
1149
|
+
valid_options = ", ".join(VALID_OUTPUT_FORMATS)
|
|
1150
|
+
suggestion = suggest_format(output_format, list(VALID_OUTPUT_FORMATS))
|
|
1151
|
+
hint = f"Hint: Use one of: {valid_options}."
|
|
1152
|
+
if suggestion:
|
|
1153
|
+
hint = f"{hint} {suggestion}"
|
|
1154
|
+
click.echo(hint, err=True)
|
|
110
1155
|
sys.exit(1)
|
|
111
1156
|
|
|
1157
|
+
# Convert format
|
|
1158
|
+
if convert_to is None and not optimize and config.get("default_convert_to"):
|
|
1159
|
+
convert_to = config["default_convert_to"]
|
|
1160
|
+
|
|
112
1161
|
# If the user specified an image path, copy it to the destination directory.
|
|
113
1162
|
if image_path:
|
|
114
1163
|
try:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
1164
|
+
# SECURITY: Validate image_path is not a symlink (PERSO-192 - critical 6th location)
|
|
1165
|
+
image_path_resolved = resolve_path_safely(image_path, check_symlink=not allow_symlinks)
|
|
1166
|
+
|
|
1167
|
+
# SECURITY: Validate file content, not just extension (PERSO-193 - CWE-434)
|
|
1168
|
+
validate_image_file(image_path_resolved, max_size_bytes=max_file_size_bytes)
|
|
119
1169
|
except ValueError as error:
|
|
120
|
-
|
|
121
|
-
|
|
1170
|
+
sanitized_error = format_path_error(error)
|
|
1171
|
+
error_msg = str(error).casefold()
|
|
1172
|
+
if "symlink" in error_msg:
|
|
1173
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {sanitized_error}", err=True)
|
|
1174
|
+
if not allow_symlinks:
|
|
1175
|
+
click.echo(
|
|
1176
|
+
"Hint: If you trust this path, rerun with `--allow-symlinks`.",
|
|
1177
|
+
err=True,
|
|
1178
|
+
)
|
|
1179
|
+
else:
|
|
1180
|
+
click.secho(f"Error: {sanitized_error}", fg="red", err=True)
|
|
1181
|
+
|
|
1182
|
+
click.echo(f"Source file: {sanitize_path_for_error(image_path)}", err=True)
|
|
1183
|
+
sys.exit(1)
|
|
1184
|
+
except FileNotFoundError:
|
|
1185
|
+
click.secho(
|
|
1186
|
+
f"Error: Image file not found: {sanitize_path_for_error(image_path)}",
|
|
1187
|
+
fg="red",
|
|
122
1188
|
err=True,
|
|
123
1189
|
)
|
|
124
|
-
click.echo(
|
|
125
|
-
click.echo(f"Source file: {image_path}", err=True)
|
|
1190
|
+
click.echo("Hint: Check the path and try again.", err=True)
|
|
126
1191
|
sys.exit(1)
|
|
127
1192
|
|
|
128
|
-
image_path = (
|
|
129
|
-
|
|
1193
|
+
image_path = (image_path_resolved,) # For compatibility with copy_screenshots()
|
|
1194
|
+
try:
|
|
1195
|
+
copied_screenshots = copy_screenshots(
|
|
1196
|
+
image_path,
|
|
1197
|
+
destination,
|
|
1198
|
+
max_file_size_bytes=max_file_size_bytes,
|
|
1199
|
+
max_total_size_bytes=max_total_size_bytes,
|
|
1200
|
+
)
|
|
1201
|
+
except ValueError as error:
|
|
1202
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1203
|
+
sys.exit(1)
|
|
130
1204
|
else:
|
|
131
1205
|
# Copy the screenshot(s) to the destination directory.
|
|
132
|
-
|
|
133
|
-
|
|
1206
|
+
try:
|
|
1207
|
+
source_screenshots = get_screenshots(
|
|
1208
|
+
source,
|
|
1209
|
+
count,
|
|
1210
|
+
max_file_size_bytes=max_file_size_bytes,
|
|
1211
|
+
allow_symlinks=allow_symlinks,
|
|
1212
|
+
)
|
|
1213
|
+
except ScreenshotNotFoundError as error:
|
|
1214
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1215
|
+
click.echo("Hint: Set `--source` or run `wslshot configure`.", err=True)
|
|
1216
|
+
sys.exit(1)
|
|
1217
|
+
try:
|
|
1218
|
+
copied_screenshots = copy_screenshots(
|
|
1219
|
+
source_screenshots,
|
|
1220
|
+
destination,
|
|
1221
|
+
max_file_size_bytes=max_file_size_bytes,
|
|
1222
|
+
max_total_size_bytes=max_total_size_bytes,
|
|
1223
|
+
)
|
|
1224
|
+
except ValueError as error:
|
|
1225
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1226
|
+
sys.exit(1)
|
|
1227
|
+
|
|
1228
|
+
# Convert images if --convert-to option is provided
|
|
1229
|
+
if convert_to:
|
|
1230
|
+
converted_screenshots: tuple[Path, ...] = ()
|
|
1231
|
+
for screenshot in copied_screenshots:
|
|
1232
|
+
try:
|
|
1233
|
+
converted_path = convert_image_format(screenshot, convert_to)
|
|
1234
|
+
converted_screenshots += (converted_path,)
|
|
1235
|
+
except ValueError as error:
|
|
1236
|
+
sanitized_error = sanitize_error_message(str(error), (screenshot,))
|
|
1237
|
+
click.secho(f"Error: {sanitized_error}", fg="red", err=True)
|
|
1238
|
+
sys.exit(1)
|
|
1239
|
+
copied_screenshots = converted_screenshots
|
|
1240
|
+
elif optimize:
|
|
1241
|
+
optimized_screenshots: tuple[Path, ...] = ()
|
|
1242
|
+
for screenshot in copied_screenshots:
|
|
1243
|
+
try:
|
|
1244
|
+
optimized_path = optimize_image(screenshot)
|
|
1245
|
+
optimized_screenshots += (optimized_path,)
|
|
1246
|
+
except ValueError as error:
|
|
1247
|
+
sanitized_error = sanitize_error_message(str(error), (screenshot,))
|
|
1248
|
+
click.secho(f"Error: {sanitized_error}", fg="red", err=True)
|
|
1249
|
+
sys.exit(1)
|
|
1250
|
+
copied_screenshots = optimized_screenshots
|
|
1251
|
+
|
|
1252
|
+
relative_screenshots: tuple[Path, ...] = ()
|
|
1253
|
+
git_root: Path | None = None
|
|
134
1254
|
|
|
135
|
-
# Automatically stage the screenshot(s) if the destination is a Git repo.
|
|
136
|
-
# But only if auto_stage is enabled in the config.
|
|
137
1255
|
if is_git_repo():
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
1256
|
+
try:
|
|
1257
|
+
git_root = get_git_root()
|
|
1258
|
+
except GitError as error:
|
|
1259
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
1260
|
+
else:
|
|
1261
|
+
relative_screenshots = format_screenshots_path_for_git(copied_screenshots, git_root)
|
|
141
1262
|
|
|
142
|
-
|
|
143
|
-
|
|
1263
|
+
if bool(config["auto_stage_enabled"]) and relative_screenshots:
|
|
1264
|
+
stage_screenshots(relative_screenshots, git_root)
|
|
1265
|
+
|
|
1266
|
+
if relative_screenshots:
|
|
1267
|
+
print_formatted_path(output_format, relative_screenshots, relative_to_repo=True)
|
|
1268
|
+
else:
|
|
1269
|
+
print_formatted_path(output_format, copied_screenshots, relative_to_repo=False)
|
|
144
1270
|
|
|
145
1271
|
|
|
146
|
-
def get_screenshots(
|
|
1272
|
+
def get_screenshots(
|
|
1273
|
+
source: str,
|
|
1274
|
+
count: int,
|
|
1275
|
+
max_file_size_bytes: int | None = None,
|
|
1276
|
+
*,
|
|
1277
|
+
allow_symlinks: bool = False,
|
|
1278
|
+
) -> tuple[Path, ...]:
|
|
147
1279
|
"""
|
|
148
1280
|
Get the most recent screenshot(s) from the source directory.
|
|
149
1281
|
|
|
150
1282
|
Args:
|
|
151
1283
|
- source: The source directory.
|
|
152
1284
|
- count: The number of screenshots to fetch.
|
|
1285
|
+
- max_file_size_bytes: Per-file size cap in bytes (None uses default).
|
|
1286
|
+
- allow_symlinks: Whether to allow symlinked files inside the source directory.
|
|
153
1287
|
|
|
154
1288
|
Returns:
|
|
155
1289
|
- The screenshot(s)'s path.
|
|
156
1290
|
"""
|
|
157
1291
|
# Get the most recent screenshot(s) from the source directory.
|
|
158
1292
|
try:
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1293
|
+
# Use scandir for efficient directory iteration (single directory scan)
|
|
1294
|
+
# Keep metadata so we can validate newest candidates first.
|
|
1295
|
+
candidates: list[tuple[float, Path, int]] = []
|
|
1296
|
+
with os.scandir(source) as entries:
|
|
1297
|
+
for entry in entries:
|
|
1298
|
+
# Check extension before stat (cheap filter)
|
|
1299
|
+
if Path(entry.name).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
1300
|
+
file_path = Path(entry.path)
|
|
1301
|
+
try:
|
|
1302
|
+
# Check symlink using entry's cached info (no extra syscall)
|
|
1303
|
+
if not allow_symlinks and entry.is_symlink():
|
|
1304
|
+
click.echo(
|
|
1305
|
+
f"{WARNING_PREFIX} Skipping symlinked file: "
|
|
1306
|
+
f"{sanitize_path_for_error(file_path)}",
|
|
1307
|
+
err=True,
|
|
1308
|
+
)
|
|
1309
|
+
continue
|
|
1310
|
+
# Stat once and check if it's a regular file
|
|
1311
|
+
stat_result = entry.stat()
|
|
1312
|
+
if S_ISREG(stat_result.st_mode):
|
|
1313
|
+
candidates.append(
|
|
1314
|
+
(stat_result.st_mtime, file_path, stat_result.st_size)
|
|
1315
|
+
)
|
|
1316
|
+
except OSError:
|
|
1317
|
+
# Skip files we can't stat (broken symlinks, permission issues, etc.)
|
|
1318
|
+
pass
|
|
1319
|
+
|
|
1320
|
+
# Validate newest candidates first so stale invalid files don't affect routine runs.
|
|
1321
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
1322
|
+
screenshots: list[Path] = []
|
|
1323
|
+
for _, file_path, file_size in candidates:
|
|
1324
|
+
try:
|
|
1325
|
+
validate_image_file(
|
|
1326
|
+
file_path,
|
|
1327
|
+
max_size_bytes=max_file_size_bytes,
|
|
1328
|
+
file_size=file_size,
|
|
1329
|
+
)
|
|
1330
|
+
screenshots.append(file_path)
|
|
1331
|
+
if len(screenshots) == count:
|
|
1332
|
+
break
|
|
1333
|
+
except ValueError as e:
|
|
1334
|
+
# Graceful degradation: skip invalid files with warning
|
|
1335
|
+
click.echo(
|
|
1336
|
+
f"{WARNING_PREFIX} Skipping invalid image file: {e}",
|
|
1337
|
+
err=True,
|
|
1338
|
+
)
|
|
167
1339
|
|
|
168
|
-
|
|
169
|
-
screenshots = screenshots[:count]
|
|
1340
|
+
sanitized_source = sanitize_path_for_error(source)
|
|
170
1341
|
|
|
171
1342
|
if len(screenshots) == 0:
|
|
172
|
-
raise
|
|
1343
|
+
raise ScreenshotNotFoundError(f"No screenshots found in {sanitized_source}")
|
|
173
1344
|
|
|
174
1345
|
if len(screenshots) < count:
|
|
175
|
-
raise
|
|
176
|
-
f"
|
|
1346
|
+
raise ScreenshotNotFoundError(
|
|
1347
|
+
f"Only {len(screenshots)} screenshot(s) found in {sanitized_source}, "
|
|
1348
|
+
f"but you asked for {count}"
|
|
177
1349
|
)
|
|
178
|
-
except
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
)
|
|
183
|
-
click.echo(f"{error}", err=True)
|
|
184
|
-
click.echo(f"Source directory: {source}\n", err=True)
|
|
185
|
-
sys.exit(1)
|
|
1350
|
+
except OSError as error:
|
|
1351
|
+
sanitized_error = format_path_error(error)
|
|
1352
|
+
raise ScreenshotNotFoundError(
|
|
1353
|
+
f"{sanitized_error} (source: {sanitize_path_for_error(source)})"
|
|
1354
|
+
) from error
|
|
186
1355
|
|
|
187
1356
|
return tuple(screenshots)
|
|
188
1357
|
|
|
189
1358
|
|
|
190
1359
|
def copy_screenshots(
|
|
191
|
-
screenshots:
|
|
192
|
-
|
|
1360
|
+
screenshots: tuple[Path, ...],
|
|
1361
|
+
destination: str,
|
|
1362
|
+
*,
|
|
1363
|
+
max_file_size_bytes: int | None = MAX_IMAGE_FILE_SIZE_BYTES,
|
|
1364
|
+
max_total_size_bytes: int | None = MAX_TOTAL_IMAGE_SIZE_BYTES,
|
|
1365
|
+
) -> tuple[Path, ...]:
|
|
193
1366
|
"""
|
|
194
|
-
Copy the screenshot(s) to the destination directory
|
|
195
|
-
and rename
|
|
1367
|
+
Copy the screenshot(s) to the destination directory
|
|
1368
|
+
and rename them with unique filesystem-friendly names.
|
|
196
1369
|
|
|
197
1370
|
Args:
|
|
198
1371
|
- screenshots: A tuple of Path objects representing the screenshot(s) to copy.
|
|
199
1372
|
- destination: The path to the destination directory.
|
|
1373
|
+
- max_file_size_bytes: Per-file size cap (None uses default).
|
|
1374
|
+
- max_total_size_bytes: Aggregate size cap (None disables cap).
|
|
200
1375
|
|
|
201
1376
|
Returns:
|
|
202
1377
|
- A tuple of Path objects representing the new locations of the copied screenshot(s).
|
|
203
1378
|
"""
|
|
204
|
-
copied_screenshots:
|
|
1379
|
+
copied_screenshots: tuple[Path, ...] = ()
|
|
1380
|
+
|
|
1381
|
+
# SECURITY: Enforce aggregate size limit to prevent DoS (PERSO-193)
|
|
1382
|
+
total_size = 0
|
|
1383
|
+
total_limit = max_total_size_bytes
|
|
1384
|
+
per_file_limit = max_file_size_bytes
|
|
1385
|
+
|
|
1386
|
+
for screenshot in screenshots:
|
|
1387
|
+
try:
|
|
1388
|
+
stat_result = screenshot.stat()
|
|
1389
|
+
except OSError as e:
|
|
1390
|
+
sanitized_error = sanitize_error_message(str(e), (screenshot,))
|
|
1391
|
+
click.echo(
|
|
1392
|
+
f"{WARNING_PREFIX} Cannot read file. Skipping: {sanitize_path_for_error(screenshot)} "
|
|
1393
|
+
f"({sanitized_error})",
|
|
1394
|
+
err=True,
|
|
1395
|
+
)
|
|
1396
|
+
continue
|
|
1397
|
+
|
|
1398
|
+
# SECURITY: Defense-in-depth validation before copying (PERSO-193 - CWE-434)
|
|
1399
|
+
try:
|
|
1400
|
+
validate_image_file(
|
|
1401
|
+
screenshot,
|
|
1402
|
+
max_size_bytes=per_file_limit,
|
|
1403
|
+
file_size=stat_result.st_size,
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
# Check total size limit
|
|
1407
|
+
total_size += stat_result.st_size
|
|
1408
|
+
|
|
1409
|
+
if total_limit is not None and total_size > total_limit:
|
|
1410
|
+
click.echo(
|
|
1411
|
+
f"{WARNING_PREFIX} Total size limit reached "
|
|
1412
|
+
f"({total_limit / 1024 / 1024:.0f}MB). Skipping remaining files.",
|
|
1413
|
+
err=True,
|
|
1414
|
+
)
|
|
1415
|
+
break
|
|
1416
|
+
|
|
1417
|
+
except ValueError as e:
|
|
1418
|
+
# Graceful degradation: skip invalid files with warning
|
|
1419
|
+
click.echo(
|
|
1420
|
+
f"{WARNING_PREFIX} Skipping invalid image file: {e}",
|
|
1421
|
+
err=True,
|
|
1422
|
+
)
|
|
1423
|
+
continue
|
|
205
1424
|
|
|
206
|
-
|
|
207
|
-
new_screenshot_name = rename_screenshot(idx, screenshot)
|
|
1425
|
+
new_screenshot_name = generate_screenshot_name(screenshot)
|
|
208
1426
|
new_screenshot_path = Path(destination) / new_screenshot_name
|
|
209
|
-
|
|
1427
|
+
try:
|
|
1428
|
+
shutil.copy(screenshot, new_screenshot_path)
|
|
1429
|
+
except OSError as e:
|
|
1430
|
+
sanitized_error = sanitize_error_message(str(e), (screenshot, new_screenshot_path))
|
|
1431
|
+
raise ValueError(
|
|
1432
|
+
f"Could not copy {sanitize_path_for_error(screenshot)} "
|
|
1433
|
+
f"to {sanitize_path_for_error(new_screenshot_path)}: {sanitized_error}"
|
|
1434
|
+
) from e
|
|
210
1435
|
copied_screenshots += (Path(destination) / new_screenshot_name,)
|
|
211
1436
|
|
|
212
1437
|
return copied_screenshots
|
|
213
1438
|
|
|
214
1439
|
|
|
215
|
-
def
|
|
1440
|
+
def generate_screenshot_name(screenshot_path: Path) -> str:
|
|
1441
|
+
"""
|
|
1442
|
+
Produce a filesystem-friendly name for a copied screenshot.
|
|
1443
|
+
"""
|
|
1444
|
+
suffix = screenshot_path.suffix.lower()
|
|
1445
|
+
unique_fragment = uuid.uuid4().hex
|
|
1446
|
+
|
|
1447
|
+
return f"{unique_fragment}{suffix}"
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def convert_image_format(source_path: Path, target_format: str) -> Path:
|
|
216
1451
|
"""
|
|
217
|
-
|
|
1452
|
+
Convert an image to a different format.
|
|
1453
|
+
|
|
1454
|
+
Args:
|
|
1455
|
+
- source_path: Path to the source image file.
|
|
1456
|
+
- target_format: Target format (png, jpg, jpeg, webp, gif).
|
|
218
1457
|
|
|
219
1458
|
Returns:
|
|
220
|
-
-
|
|
1459
|
+
- Path to the converted image (replaces original).
|
|
1460
|
+
|
|
1461
|
+
Raises:
|
|
1462
|
+
- ValueError: If conversion fails or format is unsupported.
|
|
221
1463
|
"""
|
|
222
|
-
|
|
223
|
-
|
|
1464
|
+
target_format = target_format.lower().replace(".", "")
|
|
1465
|
+
|
|
1466
|
+
# Normalize jpeg to jpg
|
|
1467
|
+
if target_format == "jpeg":
|
|
1468
|
+
target_format = "jpg"
|
|
1469
|
+
|
|
1470
|
+
# Validate target format
|
|
1471
|
+
supported_formats = {"png", "jpg", "webp", "gif"}
|
|
1472
|
+
if target_format not in supported_formats:
|
|
1473
|
+
raise ValueError(
|
|
1474
|
+
f"Unsupported target format: {target_format}. "
|
|
1475
|
+
f"Supported formats: {', '.join(sorted(supported_formats))}"
|
|
1476
|
+
)
|
|
224
1477
|
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
1478
|
+
# If already in target format, no conversion needed
|
|
1479
|
+
if source_path.suffix.lower().replace(".", "") == target_format:
|
|
1480
|
+
return source_path
|
|
228
1481
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
1482
|
+
# Precompute destination path so it can be sanitized on failure
|
|
1483
|
+
new_path = source_path.with_suffix(f".{target_format}")
|
|
1484
|
+
|
|
1485
|
+
try:
|
|
1486
|
+
with Image.open(source_path) as img:
|
|
1487
|
+
# Convert RGBA to RGB for JPEG (JPEG doesn't support transparency)
|
|
1488
|
+
if target_format == "jpg" and img.mode in ("RGBA", "LA", "P"):
|
|
1489
|
+
# Create white background
|
|
1490
|
+
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
|
|
1491
|
+
if img.mode == "P":
|
|
1492
|
+
img = img.convert("RGBA")
|
|
1493
|
+
rgb_img.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
|
|
1494
|
+
img = rgb_img
|
|
1495
|
+
|
|
1496
|
+
# Save with appropriate format
|
|
1497
|
+
if target_format == "jpg":
|
|
1498
|
+
img.save(new_path, "JPEG", quality=IMAGE_SAVE_QUALITY, optimize=True)
|
|
1499
|
+
elif target_format == "png":
|
|
1500
|
+
img.save(new_path, "PNG", optimize=True)
|
|
1501
|
+
elif target_format == "webp":
|
|
1502
|
+
img.save(new_path, "WEBP", quality=IMAGE_SAVE_QUALITY)
|
|
1503
|
+
elif target_format == "gif":
|
|
1504
|
+
img.save(new_path, "GIF", optimize=True)
|
|
1505
|
+
|
|
1506
|
+
# Remove original file if conversion created a new file
|
|
1507
|
+
if new_path != source_path:
|
|
1508
|
+
source_path.unlink()
|
|
1509
|
+
|
|
1510
|
+
return new_path
|
|
1511
|
+
|
|
1512
|
+
except Exception as e:
|
|
1513
|
+
sanitized_error = sanitize_error_message(str(e), (source_path, new_path))
|
|
1514
|
+
sanitized_path = sanitize_path_for_error(source_path)
|
|
1515
|
+
raise ValueError(f"Failed to convert image {sanitized_path}: {sanitized_error}") from e
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def optimize_image(source_path: Path) -> Path:
|
|
1519
|
+
"""
|
|
1520
|
+
Optimize an image in place while preserving filename and extension.
|
|
1521
|
+
|
|
1522
|
+
Args:
|
|
1523
|
+
- source_path: Path to the copied destination image file.
|
|
1524
|
+
|
|
1525
|
+
Returns:
|
|
1526
|
+
- Path to the optimized image (same as source path).
|
|
234
1527
|
|
|
1528
|
+
Raises:
|
|
1529
|
+
- ValueError: If optimization fails or source format is unsupported.
|
|
1530
|
+
"""
|
|
1531
|
+
source_format = source_path.suffix.lower().replace(".", "")
|
|
1532
|
+
if source_format not in {"png", "jpg", "jpeg", "gif"}:
|
|
1533
|
+
raise ValueError(f"Unsupported source format for optimization: {source_format}")
|
|
235
1534
|
|
|
236
|
-
|
|
1535
|
+
try:
|
|
1536
|
+
with Image.open(source_path) as img:
|
|
1537
|
+
if source_format in {"jpg", "jpeg"}:
|
|
1538
|
+
if img.mode in ("RGBA", "LA", "P"):
|
|
1539
|
+
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
|
|
1540
|
+
if img.mode == "P":
|
|
1541
|
+
img = img.convert("RGBA")
|
|
1542
|
+
rgb_img.paste(
|
|
1543
|
+
img,
|
|
1544
|
+
mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None,
|
|
1545
|
+
)
|
|
1546
|
+
img = rgb_img
|
|
1547
|
+
img.save(source_path, "JPEG", quality=IMAGE_SAVE_QUALITY, optimize=True)
|
|
1548
|
+
elif source_format == "png":
|
|
1549
|
+
img.save(source_path, "PNG", optimize=True)
|
|
1550
|
+
elif source_format == "gif":
|
|
1551
|
+
img.save(source_path, "GIF", optimize=True)
|
|
1552
|
+
|
|
1553
|
+
return source_path
|
|
1554
|
+
except Exception as e:
|
|
1555
|
+
sanitized_error = sanitize_error_message(str(e), (source_path,))
|
|
1556
|
+
sanitized_path = sanitize_path_for_error(source_path)
|
|
1557
|
+
raise ValueError(f"Failed to optimize image {sanitized_path}: {sanitized_error}") from e
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def stage_screenshots(screenshots: tuple[Path, ...], git_root: Path) -> None:
|
|
237
1561
|
"""
|
|
238
1562
|
Automatically stage the screenshot(s) if the destination is a Git repo.
|
|
239
1563
|
|
|
240
1564
|
Args:
|
|
241
|
-
|
|
242
1565
|
- screenshots: The screenshot(s).
|
|
1566
|
+
- git_root: The git repository root path.
|
|
243
1567
|
"""
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
try:
|
|
247
|
-
subprocess.run(["git", "add", str(screenshot)], check=True)
|
|
248
|
-
except subprocess.CalledProcessError:
|
|
249
|
-
click.echo(f"Failed to stage screenshot '{screenshot}'.")
|
|
1568
|
+
if not screenshots:
|
|
1569
|
+
return
|
|
250
1570
|
|
|
251
|
-
|
|
252
|
-
|
|
1571
|
+
try:
|
|
1572
|
+
# Try batch staging first for performance
|
|
1573
|
+
subprocess.run(
|
|
1574
|
+
["git", "add"] + [str(screenshot) for screenshot in screenshots],
|
|
1575
|
+
check=True,
|
|
1576
|
+
cwd=git_root,
|
|
1577
|
+
)
|
|
1578
|
+
except FileNotFoundError:
|
|
1579
|
+
click.echo(f"{WARNING_PREFIX} Git not found; skipping auto-staging.", err=True)
|
|
1580
|
+
return
|
|
1581
|
+
except subprocess.CalledProcessError:
|
|
1582
|
+
# Batch staging failed - fall back to individual staging
|
|
1583
|
+
# This ensures valid files are staged even if some fail
|
|
1584
|
+
hinted = False
|
|
1585
|
+
for screenshot in screenshots:
|
|
1586
|
+
try:
|
|
1587
|
+
subprocess.run(
|
|
1588
|
+
["git", "add", str(screenshot)],
|
|
1589
|
+
check=True,
|
|
1590
|
+
cwd=git_root,
|
|
1591
|
+
)
|
|
1592
|
+
except FileNotFoundError:
|
|
1593
|
+
click.echo(f"{WARNING_PREFIX} Git not found; skipping auto-staging.", err=True)
|
|
1594
|
+
return
|
|
1595
|
+
except subprocess.CalledProcessError:
|
|
1596
|
+
click.echo(
|
|
1597
|
+
f"{WARNING_PREFIX} Auto-staging failed for {screenshot}; "
|
|
1598
|
+
"continuing without staging.",
|
|
1599
|
+
err=True,
|
|
1600
|
+
)
|
|
1601
|
+
if not hinted:
|
|
1602
|
+
click.echo(
|
|
1603
|
+
"Hint: Disable it with `wslshot configure --auto-stage-enabled false`, "
|
|
1604
|
+
"or run `git add` yourself.",
|
|
1605
|
+
err=True,
|
|
1606
|
+
)
|
|
1607
|
+
hinted = True
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def format_screenshots_path_for_git(
|
|
1611
|
+
screenshots: tuple[Path, ...], git_root: Path
|
|
1612
|
+
) -> tuple[Path, ...]:
|
|
253
1613
|
"""
|
|
254
1614
|
Format the screenshot(s)'s path for git.
|
|
255
1615
|
|
|
@@ -257,16 +1617,20 @@ def format_screenshots_path_for_git(screenshots: Tuple[Path]) -> Tuple[Path, ...
|
|
|
257
1617
|
|
|
258
1618
|
- screenshots: The screenshot(s).
|
|
259
1619
|
"""
|
|
260
|
-
|
|
261
|
-
formatted_screenshots: Tuple[Path, ...] = ()
|
|
1620
|
+
formatted_screenshots: tuple[Path, ...] = ()
|
|
262
1621
|
|
|
263
1622
|
for screenshot in screenshots:
|
|
264
|
-
|
|
1623
|
+
try:
|
|
1624
|
+
formatted_screenshots += (Path(screenshot).relative_to(git_root),)
|
|
1625
|
+
except ValueError:
|
|
1626
|
+
continue
|
|
265
1627
|
|
|
266
1628
|
return formatted_screenshots
|
|
267
1629
|
|
|
268
1630
|
|
|
269
|
-
def print_formatted_path(
|
|
1631
|
+
def print_formatted_path(
|
|
1632
|
+
output_format: str, screenshots: tuple[Path, ...], *, relative_to_repo: bool
|
|
1633
|
+
) -> None:
|
|
270
1634
|
"""
|
|
271
1635
|
Print the screenshot(s)'s path in the specified format.
|
|
272
1636
|
|
|
@@ -275,66 +1639,325 @@ def print_formatted_path(output_format: str, screenshots: Tuple[Path]) -> None:
|
|
|
275
1639
|
- output_format: The output format.
|
|
276
1640
|
- screenshots: The screenshot(s).
|
|
277
1641
|
"""
|
|
1642
|
+
normalized_output_format = output_format.casefold()
|
|
278
1643
|
for screenshot in screenshots:
|
|
279
1644
|
# Adding a '/' to the screenshot path if the destination is a Git repo.
|
|
280
1645
|
# This is because the screenshot path is relative to the git repo's.
|
|
281
|
-
if
|
|
282
|
-
screenshot_path = f"/{screenshot}"
|
|
283
|
-
else:
|
|
284
|
-
screenshot_path = str(screenshot) # This is an absolute path.
|
|
1646
|
+
screenshot_path = f"/{screenshot}" if relative_to_repo else str(screenshot)
|
|
285
1647
|
|
|
286
|
-
if
|
|
287
|
-
|
|
1648
|
+
if normalized_output_format == OUTPUT_FORMAT_MARKDOWN:
|
|
1649
|
+
click.echo(f"")
|
|
288
1650
|
|
|
289
|
-
elif
|
|
290
|
-
|
|
1651
|
+
elif normalized_output_format == OUTPUT_FORMAT_HTML:
|
|
1652
|
+
click.echo(f'<img src="{screenshot_path}" alt="{screenshot.name}">')
|
|
291
1653
|
|
|
292
|
-
elif
|
|
293
|
-
|
|
1654
|
+
elif normalized_output_format == OUTPUT_FORMAT_TEXT:
|
|
1655
|
+
click.echo(screenshot_path)
|
|
294
1656
|
|
|
295
1657
|
else:
|
|
296
|
-
|
|
1658
|
+
valid_options = ", ".join(VALID_OUTPUT_FORMATS)
|
|
1659
|
+
click.echo(f"Error: Invalid `--output-style`: {output_format}", err=True)
|
|
1660
|
+
click.echo(f"Hint: Use one of: {valid_options}.", err=True)
|
|
297
1661
|
sys.exit(1)
|
|
298
1662
|
|
|
299
1663
|
|
|
300
|
-
def get_config_file_path() -> Path:
|
|
1664
|
+
def get_config_file_path(*, create_if_missing: bool = True) -> Path:
|
|
301
1665
|
"""
|
|
302
|
-
|
|
1666
|
+
Get the configuration file path, optionally creating the file.
|
|
303
1667
|
"""
|
|
304
|
-
|
|
305
|
-
config_file_path
|
|
1668
|
+
config_dir = Path.home() / CONFIG_DIR_RELATIVE
|
|
1669
|
+
config_file_path = config_dir / CONFIG_FILE_NAME
|
|
1670
|
+
|
|
1671
|
+
# Validate symlink target shape early for clearer errors.
|
|
1672
|
+
if config_file_path.is_symlink():
|
|
1673
|
+
resolve_config_data_path(config_file_path)
|
|
1674
|
+
|
|
1675
|
+
if create_if_missing:
|
|
1676
|
+
create_directory_safely(config_file_path.parent, mode=CONFIG_DIR_PERMISSIONS)
|
|
306
1677
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
1678
|
+
if not config_file_path.exists():
|
|
1679
|
+
# Write default config without interactive prompts
|
|
1680
|
+
write_config_safely(config_file_path, DEFAULT_CONFIG.copy())
|
|
310
1681
|
|
|
311
1682
|
return config_file_path
|
|
312
1683
|
|
|
313
1684
|
|
|
314
|
-
def
|
|
1685
|
+
def get_config_file_path_or_exit(*, create_if_missing: bool = True) -> Path:
|
|
1686
|
+
"""Return config path or exit with a user-friendly security error."""
|
|
1687
|
+
try:
|
|
1688
|
+
return get_config_file_path(create_if_missing=create_if_missing)
|
|
1689
|
+
except SecurityError as error:
|
|
1690
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {error}", err=True)
|
|
1691
|
+
error_msg = str(error).lower()
|
|
1692
|
+
if "symlink loop" in error_msg:
|
|
1693
|
+
click.echo(
|
|
1694
|
+
"Hint: Fix the symlink at ~/.config/wslshot/config.json, then rerun this command.",
|
|
1695
|
+
err=True,
|
|
1696
|
+
)
|
|
1697
|
+
elif "directory" in error_msg:
|
|
1698
|
+
click.echo("Hint: Point ~/.config/wslshot/config.json to a regular file.", err=True)
|
|
1699
|
+
elif "different user" in error_msg:
|
|
1700
|
+
click.echo("Hint: Check directory ownership or use a different path.", err=True)
|
|
1701
|
+
sys.exit(1)
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def validate_config(
|
|
1705
|
+
raw_config: dict[str, object],
|
|
1706
|
+
*,
|
|
1707
|
+
skip_fields: set[str] | None = None,
|
|
1708
|
+
) -> dict[str, object]:
|
|
1709
|
+
"""
|
|
1710
|
+
Validate configuration against schema and normalize values.
|
|
1711
|
+
|
|
1712
|
+
Uses CONFIG_FIELD_SPECS to validate types and values. Missing keys
|
|
1713
|
+
are filled with defaults. Unknown keys trigger a warning.
|
|
1714
|
+
|
|
1715
|
+
For directory paths (default_source, default_destination), non-existent
|
|
1716
|
+
paths are allowed with a warning, not an error. The user might configure
|
|
1717
|
+
paths that will be created later.
|
|
1718
|
+
|
|
1719
|
+
Args:
|
|
1720
|
+
raw_config: Raw config dictionary from JSON
|
|
1721
|
+
skip_fields: Config fields to keep as-is without validation
|
|
1722
|
+
|
|
1723
|
+
Returns:
|
|
1724
|
+
Validated config with all required keys
|
|
1725
|
+
|
|
1726
|
+
Raises:
|
|
1727
|
+
ConfigurationError: If a value fails validation (except path existence)
|
|
1728
|
+
"""
|
|
1729
|
+
# Reject non-dict JSON (e.g., [], "", 123) early
|
|
1730
|
+
if not isinstance(raw_config, dict):
|
|
1731
|
+
raise ConfigurationError(
|
|
1732
|
+
f"Invalid config format: expected object, got {type(raw_config).__name__}"
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
validated: dict[str, object] = {}
|
|
1736
|
+
|
|
1737
|
+
# Fields where non-existent paths are allowed (warn, don't fail)
|
|
1738
|
+
path_fields = {"default_source", "default_destination"}
|
|
1739
|
+
|
|
1740
|
+
# Check for unknown keys (potential typos)
|
|
1741
|
+
known_keys = set(CONFIG_FIELD_SPECS.keys())
|
|
1742
|
+
unknown_keys = set(raw_config.keys()) - known_keys
|
|
1743
|
+
if unknown_keys:
|
|
1744
|
+
click.echo(
|
|
1745
|
+
f"{WARNING_PREFIX} Unknown config keys ignored: {', '.join(sorted(unknown_keys))}",
|
|
1746
|
+
err=True,
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
skip_fields = set(skip_fields or set())
|
|
1750
|
+
|
|
1751
|
+
# Validate each expected field
|
|
1752
|
+
for field, spec in CONFIG_FIELD_SPECS.items():
|
|
1753
|
+
if field not in raw_config:
|
|
1754
|
+
# Use default for missing fields
|
|
1755
|
+
validated[field] = spec.default
|
|
1756
|
+
continue
|
|
1757
|
+
|
|
1758
|
+
if field in skip_fields:
|
|
1759
|
+
raw_value = raw_config[field]
|
|
1760
|
+
if raw_value is None:
|
|
1761
|
+
validated[field] = spec.default
|
|
1762
|
+
elif isinstance(raw_value, Path):
|
|
1763
|
+
validated[field] = str(raw_value)
|
|
1764
|
+
elif isinstance(raw_value, str):
|
|
1765
|
+
validated[field] = raw_value if raw_value.strip() else spec.default
|
|
1766
|
+
else:
|
|
1767
|
+
validated[field] = spec.default
|
|
1768
|
+
continue
|
|
1769
|
+
|
|
1770
|
+
try:
|
|
1771
|
+
validated[field] = spec.normalize(raw_config[field])
|
|
1772
|
+
except FileNotFoundError:
|
|
1773
|
+
# For path fields, non-existent paths are allowed with a warning
|
|
1774
|
+
if field in path_fields:
|
|
1775
|
+
raw_value = raw_config[field]
|
|
1776
|
+
if isinstance(raw_value, str) and raw_value.strip():
|
|
1777
|
+
click.echo(
|
|
1778
|
+
f"{WARNING_PREFIX} Configured {field.replace('_', ' ')} does not exist: "
|
|
1779
|
+
f"{sanitize_path_for_error(raw_value)}",
|
|
1780
|
+
err=True,
|
|
1781
|
+
)
|
|
1782
|
+
validated[field] = raw_value
|
|
1783
|
+
else:
|
|
1784
|
+
validated[field] = spec.default
|
|
1785
|
+
else:
|
|
1786
|
+
raise ConfigurationError(
|
|
1787
|
+
f"Invalid value for '{field}': path does not exist"
|
|
1788
|
+
) from None
|
|
1789
|
+
except (TypeError, ValueError) as error:
|
|
1790
|
+
raise ConfigurationError(f"Invalid value for '{field}': {error}") from error
|
|
1791
|
+
|
|
1792
|
+
return validated
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def read_config(
|
|
1796
|
+
config_file_path: Path,
|
|
1797
|
+
*,
|
|
1798
|
+
skip_fields: set[str] | None = None,
|
|
1799
|
+
) -> dict[str, object]:
|
|
315
1800
|
"""
|
|
316
1801
|
Read the configuration file.
|
|
317
1802
|
|
|
318
|
-
|
|
1803
|
+
This function expects `config_file_path` to exist. Use `get_config_file_path()` when you
|
|
1804
|
+
want to create a default config file if missing.
|
|
319
1805
|
|
|
320
1806
|
Args:
|
|
321
1807
|
config_file_path: The path to the configuration file.
|
|
1808
|
+
skip_fields: Config fields to keep as-is without validation.
|
|
322
1809
|
|
|
323
1810
|
Returns:
|
|
324
1811
|
The configuration file as a dictionary.
|
|
325
1812
|
"""
|
|
326
1813
|
try:
|
|
327
1814
|
with open(config_file_path, "r", encoding="UTF-8") as file:
|
|
328
|
-
|
|
1815
|
+
raw_config = json.load(file)
|
|
1816
|
+
if not isinstance(raw_config, dict):
|
|
1817
|
+
raise ConfigurationError(
|
|
1818
|
+
f"Invalid config format: expected object, got {type(raw_config).__name__}"
|
|
1819
|
+
)
|
|
1820
|
+
config = validate_config(raw_config, skip_fields=skip_fields)
|
|
329
1821
|
|
|
330
|
-
except json.JSONDecodeError:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
1822
|
+
except (json.JSONDecodeError, ConfigurationError) as error:
|
|
1823
|
+
if _is_interactive_terminal():
|
|
1824
|
+
click.echo(
|
|
1825
|
+
f"{WARNING_PREFIX} Config file {sanitize_path_for_error(config_file_path)} is corrupted ({error}). "
|
|
1826
|
+
"We'll recreate it interactively.",
|
|
1827
|
+
err=True,
|
|
1828
|
+
)
|
|
1829
|
+
_backup_corrupted_file_or_warn(config_file_path)
|
|
1830
|
+
write_config(config_file_path)
|
|
1831
|
+
with open(config_file_path, "r", encoding="UTF-8") as file:
|
|
1832
|
+
config = json.load(file)
|
|
1833
|
+
return config
|
|
1834
|
+
|
|
1835
|
+
click.echo(
|
|
1836
|
+
f"{WARNING_PREFIX} Config file {sanitize_path_for_error(config_file_path)} is corrupted ({error}). "
|
|
1837
|
+
"Resetting to defaults.",
|
|
1838
|
+
err=True,
|
|
1839
|
+
)
|
|
1840
|
+
click.echo("Hint: Run `wslshot configure` to set your preferences.", err=True)
|
|
1841
|
+
|
|
1842
|
+
_backup_corrupted_file_or_warn(config_file_path)
|
|
1843
|
+
|
|
1844
|
+
create_directory_safely(config_file_path.parent, mode=CONFIG_DIR_PERMISSIONS)
|
|
1845
|
+
config = DEFAULT_CONFIG.copy()
|
|
1846
|
+
write_config_or_exit(config_file_path, config)
|
|
334
1847
|
|
|
335
1848
|
return config
|
|
336
1849
|
|
|
337
1850
|
|
|
1851
|
+
def read_config_readonly(
|
|
1852
|
+
config_file_path: Path,
|
|
1853
|
+
*,
|
|
1854
|
+
skip_fields: set[str] | None = None,
|
|
1855
|
+
) -> dict[str, object]:
|
|
1856
|
+
"""
|
|
1857
|
+
Read the configuration file without writing changes.
|
|
1858
|
+
|
|
1859
|
+
If the config is invalid or unreadable, fall back to defaults and emit a warning.
|
|
1860
|
+
|
|
1861
|
+
Args:
|
|
1862
|
+
config_file_path: The path to the configuration file.
|
|
1863
|
+
skip_fields: Config fields to keep as-is without validation.
|
|
1864
|
+
"""
|
|
1865
|
+
try:
|
|
1866
|
+
with open(config_file_path, "r", encoding="UTF-8") as file:
|
|
1867
|
+
raw_config = json.load(file)
|
|
1868
|
+
if not isinstance(raw_config, dict):
|
|
1869
|
+
raise ConfigurationError(
|
|
1870
|
+
f"Invalid config format: expected object, got {type(raw_config).__name__}"
|
|
1871
|
+
)
|
|
1872
|
+
return validate_config(raw_config, skip_fields=skip_fields)
|
|
1873
|
+
except (json.JSONDecodeError, ConfigurationError, OSError) as error:
|
|
1874
|
+
click.echo(
|
|
1875
|
+
f"{WARNING_PREFIX} Config file {sanitize_path_for_error(config_file_path)} is unreadable or invalid "
|
|
1876
|
+
f"({error}). Ignoring it for this run.",
|
|
1877
|
+
err=True,
|
|
1878
|
+
)
|
|
1879
|
+
click.echo("Hint: Run `wslshot configure` to set your preferences.", err=True)
|
|
1880
|
+
return DEFAULT_CONFIG.copy()
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def migrate_config(config_path: Path, *, dry_run: bool = False) -> dict[str, object]:
|
|
1884
|
+
"""
|
|
1885
|
+
Migrate legacy config values to current format.
|
|
1886
|
+
|
|
1887
|
+
Migrations performed:
|
|
1888
|
+
- `plain_text` becomes `text` in `default_output_format`
|
|
1889
|
+
|
|
1890
|
+
Args:
|
|
1891
|
+
config_path: Path to config file
|
|
1892
|
+
dry_run: If True, return changes without writing
|
|
1893
|
+
|
|
1894
|
+
Returns:
|
|
1895
|
+
Dictionary with migration report:
|
|
1896
|
+
{
|
|
1897
|
+
"migrated": bool,
|
|
1898
|
+
"changes": list[str],
|
|
1899
|
+
"config": dict
|
|
1900
|
+
}
|
|
1901
|
+
"""
|
|
1902
|
+
try:
|
|
1903
|
+
with open(config_path, "r", encoding="UTF-8") as f:
|
|
1904
|
+
config = json.load(f)
|
|
1905
|
+
except FileNotFoundError as e:
|
|
1906
|
+
sanitized_error = format_path_error(e)
|
|
1907
|
+
return {
|
|
1908
|
+
"migrated": False,
|
|
1909
|
+
"changes": [],
|
|
1910
|
+
"error": f"Cannot read config file: {sanitized_error}",
|
|
1911
|
+
"config": {},
|
|
1912
|
+
}
|
|
1913
|
+
except json.JSONDecodeError as e:
|
|
1914
|
+
return {
|
|
1915
|
+
"migrated": False,
|
|
1916
|
+
"changes": [],
|
|
1917
|
+
"error": f"Cannot read config file: {e}",
|
|
1918
|
+
"config": {},
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
# Validate config is a dictionary
|
|
1922
|
+
if not isinstance(config, dict):
|
|
1923
|
+
return {
|
|
1924
|
+
"migrated": False,
|
|
1925
|
+
"changes": [],
|
|
1926
|
+
"error": f"Invalid config format: expected an object, got {type(config).__name__}",
|
|
1927
|
+
"config": {},
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
changes = []
|
|
1931
|
+
|
|
1932
|
+
# Migration: plain_text becomes text
|
|
1933
|
+
default_output_format = config.get("default_output_format")
|
|
1934
|
+
if (
|
|
1935
|
+
isinstance(default_output_format, str)
|
|
1936
|
+
and default_output_format.casefold() == LEGACY_OUTPUT_FORMAT_PLAIN_TEXT
|
|
1937
|
+
):
|
|
1938
|
+
config["default_output_format"] = OUTPUT_FORMAT_TEXT
|
|
1939
|
+
changes.append("default_output_format: 'plain_text' becomes 'text'")
|
|
1940
|
+
|
|
1941
|
+
# Write migrated config
|
|
1942
|
+
if changes and not dry_run:
|
|
1943
|
+
try:
|
|
1944
|
+
write_config_safely(config_path, config)
|
|
1945
|
+
except (OSError, SecurityError) as e:
|
|
1946
|
+
sanitized_error = sanitize_error_message(str(e), (config_path,))
|
|
1947
|
+
return {
|
|
1948
|
+
"migrated": False,
|
|
1949
|
+
"changes": changes,
|
|
1950
|
+
"error": f"Cannot write config file: {sanitized_error}",
|
|
1951
|
+
"config": config,
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
return {
|
|
1955
|
+
"migrated": bool(changes) and not dry_run,
|
|
1956
|
+
"changes": changes,
|
|
1957
|
+
"config": config,
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
|
|
338
1961
|
def write_config(config_file_path: Path) -> None:
|
|
339
1962
|
"""
|
|
340
1963
|
Write the configuration file.
|
|
@@ -351,66 +1974,74 @@ def write_config(config_file_path: Path) -> None:
|
|
|
351
1974
|
current_config = {}
|
|
352
1975
|
|
|
353
1976
|
if current_config:
|
|
354
|
-
click.
|
|
1977
|
+
click.secho("Updating config file...", fg="yellow")
|
|
355
1978
|
else:
|
|
356
|
-
click.
|
|
1979
|
+
click.secho("Creating config file...", fg="yellow")
|
|
357
1980
|
click.echo()
|
|
358
1981
|
|
|
359
|
-
# Configuration fields
|
|
360
|
-
config_fields = {
|
|
361
|
-
"default_source": ("Enter the path for the default source directory", ""),
|
|
362
|
-
"default_destination": (
|
|
363
|
-
"Enter the path for the default destination directory",
|
|
364
|
-
"",
|
|
365
|
-
),
|
|
366
|
-
"auto_stage_enabled": (
|
|
367
|
-
"Automatically stage screenshots when copying to a git repository?",
|
|
368
|
-
False,
|
|
369
|
-
),
|
|
370
|
-
"default_output_format": (
|
|
371
|
-
"Enter the default output format (markdown, html, plain_text)",
|
|
372
|
-
"markdown",
|
|
373
|
-
),
|
|
374
|
-
}
|
|
375
|
-
|
|
376
1982
|
# Prompt the user for configuration values.
|
|
377
|
-
config = {}
|
|
378
|
-
for field,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
config[field] =
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1983
|
+
config: dict[str, object] = {}
|
|
1984
|
+
for field, spec in CONFIG_FIELD_SPECS.items():
|
|
1985
|
+
message = spec.prompt
|
|
1986
|
+
default = spec.default
|
|
1987
|
+
|
|
1988
|
+
if field in ("default_source", "default_destination"):
|
|
1989
|
+
value = get_validated_directory_input(field, message, current_config, default)
|
|
1990
|
+
config[field] = spec.normalize(value)
|
|
1991
|
+
continue
|
|
1992
|
+
|
|
1993
|
+
if field == "auto_stage_enabled":
|
|
1994
|
+
value = get_config_boolean_input(field, message, current_config, default)
|
|
1995
|
+
config[field] = spec.normalize(value)
|
|
1996
|
+
continue
|
|
1997
|
+
|
|
1998
|
+
if field == "default_output_format":
|
|
1999
|
+
value = get_validated_input(
|
|
389
2000
|
field,
|
|
390
2001
|
message,
|
|
391
2002
|
current_config,
|
|
392
2003
|
default,
|
|
393
|
-
options=
|
|
2004
|
+
options=list(VALID_OUTPUT_FORMATS),
|
|
394
2005
|
)
|
|
395
|
-
|
|
396
|
-
|
|
2006
|
+
config[field] = spec.normalize(value)
|
|
2007
|
+
continue
|
|
2008
|
+
|
|
2009
|
+
if field == "default_convert_to":
|
|
2010
|
+
while True:
|
|
2011
|
+
value = get_config_input(field, message, current_config, default or "")
|
|
2012
|
+
try:
|
|
2013
|
+
config[field] = spec.normalize(value)
|
|
2014
|
+
except ValueError as error:
|
|
2015
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
2016
|
+
click.echo()
|
|
2017
|
+
continue
|
|
2018
|
+
break
|
|
2019
|
+
continue
|
|
2020
|
+
|
|
2021
|
+
if field in ("max_file_size_mb", "max_total_size_mb"):
|
|
2022
|
+
value = get_config_input(field, message, current_config, default)
|
|
2023
|
+
try:
|
|
2024
|
+
config[field] = spec.normalize(value)
|
|
2025
|
+
except (TypeError, ValueError):
|
|
2026
|
+
config[field] = default
|
|
2027
|
+
continue
|
|
2028
|
+
|
|
2029
|
+
value = get_config_input(field, message, current_config, default)
|
|
2030
|
+
config[field] = spec.normalize(value)
|
|
397
2031
|
|
|
398
2032
|
# Writing configuration to file
|
|
399
|
-
|
|
400
|
-
with open(config_file_path, "w", encoding="UTF-8") as file:
|
|
401
|
-
json.dump(config, file, indent=4)
|
|
402
|
-
except FileNotFoundError as error:
|
|
403
|
-
click.echo(f"Failed to write configuration file: {error}", err=True)
|
|
404
|
-
sys.exit(1)
|
|
2033
|
+
write_config_or_exit(config_file_path, config)
|
|
405
2034
|
|
|
406
2035
|
if current_config:
|
|
407
|
-
click.
|
|
2036
|
+
click.secho("Configuration saved.", fg="green")
|
|
408
2037
|
else:
|
|
409
|
-
click.
|
|
2038
|
+
click.secho("Configuration file created.", fg="green")
|
|
410
2039
|
|
|
411
2040
|
|
|
412
2041
|
def get_config_input(field, message, current_config, default="") -> str:
|
|
413
2042
|
existing = current_config.get(field, default)
|
|
2043
|
+
if existing is None:
|
|
2044
|
+
existing = default
|
|
414
2045
|
return click.prompt(
|
|
415
2046
|
click.style(message, fg="blue"),
|
|
416
2047
|
type=str,
|
|
@@ -436,19 +2067,22 @@ def get_validated_directory_input(field, message, current_config, default) -> st
|
|
|
436
2067
|
return default
|
|
437
2068
|
|
|
438
2069
|
try:
|
|
439
|
-
return str(
|
|
2070
|
+
return str(resolve_path_safely(directory))
|
|
2071
|
+
except ValueError as error:
|
|
2072
|
+
sanitized_msg = format_path_error(error)
|
|
2073
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {sanitized_msg}", err=True)
|
|
440
2074
|
except FileNotFoundError as error:
|
|
441
|
-
|
|
442
|
-
|
|
2075
|
+
sanitized_msg = format_path_error(error)
|
|
2076
|
+
click.secho(
|
|
2077
|
+
f"Error: Invalid {field.replace('_', ' ')}: {sanitized_msg}",
|
|
2078
|
+
fg="red",
|
|
443
2079
|
err=True,
|
|
444
2080
|
)
|
|
445
2081
|
finally:
|
|
446
2082
|
click.echo()
|
|
447
2083
|
|
|
448
2084
|
|
|
449
|
-
def get_validated_input(
|
|
450
|
-
field, message, current_config, default="", options=None
|
|
451
|
-
) -> str:
|
|
2085
|
+
def get_validated_input(field, message, current_config, default="", options=None) -> str:
|
|
452
2086
|
existing = current_config.get(field, default)
|
|
453
2087
|
|
|
454
2088
|
while True:
|
|
@@ -460,38 +2094,82 @@ def get_validated_input(
|
|
|
460
2094
|
)
|
|
461
2095
|
|
|
462
2096
|
if options and value.lower() not in options:
|
|
463
|
-
click.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
2097
|
+
click.secho(
|
|
2098
|
+
f"Error: Invalid value for {field.replace('_', ' ')}. "
|
|
2099
|
+
f"Use one of: {', '.join(options)}.",
|
|
2100
|
+
fg="red",
|
|
2101
|
+
err=True,
|
|
468
2102
|
)
|
|
469
2103
|
continue
|
|
470
2104
|
|
|
471
2105
|
return value
|
|
472
2106
|
|
|
473
2107
|
|
|
474
|
-
def
|
|
2108
|
+
def _write_config_field(field: str, normalized_value: object) -> None:
|
|
475
2109
|
"""
|
|
476
|
-
|
|
2110
|
+
Persist a single, already-normalized config field value.
|
|
2111
|
+
|
|
2112
|
+
This helper centralizes the read, update, and write sequence for setter functions that
|
|
2113
|
+
perform their own validation and normalization.
|
|
2114
|
+
"""
|
|
2115
|
+
config_file_path = get_config_file_path_or_exit()
|
|
2116
|
+
config = read_config(config_file_path)
|
|
2117
|
+
config[field] = normalized_value
|
|
2118
|
+
|
|
2119
|
+
write_config_or_exit(config_file_path, config)
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
def update_config_field(field: str, value: object) -> None:
|
|
2123
|
+
"""
|
|
2124
|
+
Update a single config field.
|
|
477
2125
|
|
|
478
2126
|
Args:
|
|
479
|
-
|
|
2127
|
+
field: Config key to update
|
|
2128
|
+
value: New value for the field
|
|
2129
|
+
|
|
2130
|
+
Raises:
|
|
2131
|
+
click.ClickException: If `field` is not a valid config key or `value` is invalid
|
|
480
2132
|
"""
|
|
2133
|
+
spec = CONFIG_FIELD_SPECS.get(field)
|
|
2134
|
+
if spec is None:
|
|
2135
|
+
raise click.ClickException(f"Unknown config field: {field}")
|
|
2136
|
+
|
|
481
2137
|
try:
|
|
482
|
-
|
|
483
|
-
except FileNotFoundError as error:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
2138
|
+
normalized_value = spec.normalize(value)
|
|
2139
|
+
except (ValueError, TypeError, FileNotFoundError) as error:
|
|
2140
|
+
sanitized = format_path_error(error)
|
|
2141
|
+
raise click.ClickException(
|
|
2142
|
+
f"Invalid value for {field}: {sanitized}\n"
|
|
2143
|
+
"Hint: See `wslshot configure --help` for valid values."
|
|
2144
|
+
) from error
|
|
488
2145
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
2146
|
+
_write_config_field(field, normalized_value)
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def set_default_source(source_str: str) -> None:
|
|
2150
|
+
"""
|
|
2151
|
+
Set the default source directory.
|
|
2152
|
+
|
|
2153
|
+
Args:
|
|
2154
|
+
source_str: The default source directory.
|
|
2155
|
+
|
|
2156
|
+
Raises:
|
|
2157
|
+
SecurityError: If path contains symlinks (rejected for security).
|
|
2158
|
+
ConfigurationError: If the source directory is invalid.
|
|
2159
|
+
"""
|
|
2160
|
+
if not source_str.strip():
|
|
2161
|
+
source = ""
|
|
2162
|
+
else:
|
|
2163
|
+
try:
|
|
2164
|
+
source = str(resolve_path_safely(source_str))
|
|
2165
|
+
except ValueError as error:
|
|
2166
|
+
sanitized_msg = format_path_error(error)
|
|
2167
|
+
raise SecurityError(sanitized_msg) from error
|
|
2168
|
+
except FileNotFoundError as error:
|
|
2169
|
+
sanitized_msg = format_path_error(error)
|
|
2170
|
+
raise ConfigurationError(f"Invalid source directory: {sanitized_msg}") from error
|
|
492
2171
|
|
|
493
|
-
|
|
494
|
-
json.dump(config, file, indent=4)
|
|
2172
|
+
_write_config_field("default_source", source)
|
|
495
2173
|
|
|
496
2174
|
|
|
497
2175
|
def set_default_destination(destination_str: str) -> None:
|
|
@@ -499,22 +2177,25 @@ def set_default_destination(destination_str: str) -> None:
|
|
|
499
2177
|
Set the default destination directory.
|
|
500
2178
|
|
|
501
2179
|
Args:
|
|
502
|
-
|
|
503
|
-
"""
|
|
504
|
-
try:
|
|
505
|
-
destination: Path = str(Path(destination_str).resolve(strict=True))
|
|
506
|
-
except FileNotFoundError as error:
|
|
507
|
-
click.echo(
|
|
508
|
-
click.style(f"Invalid destination directory: {error}", fg="red"), err=True
|
|
509
|
-
)
|
|
510
|
-
sys.exit(1)
|
|
2180
|
+
destination_str: The default destination directory.
|
|
511
2181
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
2182
|
+
Raises:
|
|
2183
|
+
SecurityError: If path contains symlinks (rejected for security).
|
|
2184
|
+
ConfigurationError: If the destination directory is invalid.
|
|
2185
|
+
"""
|
|
2186
|
+
if not destination_str.strip():
|
|
2187
|
+
destination = ""
|
|
2188
|
+
else:
|
|
2189
|
+
try:
|
|
2190
|
+
destination = str(resolve_path_safely(destination_str))
|
|
2191
|
+
except ValueError as error:
|
|
2192
|
+
sanitized_msg = format_path_error(error)
|
|
2193
|
+
raise SecurityError(sanitized_msg) from error
|
|
2194
|
+
except FileNotFoundError as error:
|
|
2195
|
+
sanitized_msg = format_path_error(error)
|
|
2196
|
+
raise ConfigurationError(f"Invalid destination directory: {sanitized_msg}") from error
|
|
515
2197
|
|
|
516
|
-
|
|
517
|
-
json.dump(config, file, indent=4)
|
|
2198
|
+
_write_config_field("default_destination", destination)
|
|
518
2199
|
|
|
519
2200
|
|
|
520
2201
|
def get_destination() -> Path:
|
|
@@ -523,11 +2204,15 @@ def get_destination() -> Path:
|
|
|
523
2204
|
|
|
524
2205
|
Returns:
|
|
525
2206
|
The destination directory.
|
|
2207
|
+
|
|
2208
|
+
Raises:
|
|
2209
|
+
GitError: If inside a Git repo and git root cannot be determined.
|
|
2210
|
+
SecurityError: If inside a Git repo and directory creation fails due to security violation.
|
|
526
2211
|
"""
|
|
527
2212
|
if is_git_repo():
|
|
528
2213
|
return get_git_repo_img_destination()
|
|
529
2214
|
|
|
530
|
-
config = read_config(
|
|
2215
|
+
config = read_config(get_config_file_path_or_exit())
|
|
531
2216
|
if config["default_destination"]:
|
|
532
2217
|
return Path(config["default_destination"])
|
|
533
2218
|
|
|
@@ -548,46 +2233,56 @@ def is_git_repo() -> bool:
|
|
|
548
2233
|
stderr=subprocess.DEVNULL,
|
|
549
2234
|
check=True,
|
|
550
2235
|
)
|
|
551
|
-
except subprocess.CalledProcessError:
|
|
2236
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
552
2237
|
return False
|
|
553
2238
|
|
|
554
2239
|
return True
|
|
555
2240
|
|
|
556
2241
|
|
|
2242
|
+
def get_git_root() -> Path:
|
|
2243
|
+
"""
|
|
2244
|
+
Get the absolute path to the current git repository root.
|
|
2245
|
+
|
|
2246
|
+
Raises:
|
|
2247
|
+
GitError: If the git repository root cannot be determined.
|
|
2248
|
+
"""
|
|
2249
|
+
try:
|
|
2250
|
+
git_root_bytes = subprocess.run(
|
|
2251
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
2252
|
+
check=True,
|
|
2253
|
+
stdout=subprocess.PIPE,
|
|
2254
|
+
stderr=subprocess.PIPE,
|
|
2255
|
+
).stdout
|
|
2256
|
+
except FileNotFoundError as error:
|
|
2257
|
+
raise GitError("Git executable not found.") from error
|
|
2258
|
+
except subprocess.CalledProcessError as error:
|
|
2259
|
+
raise GitError("Could not determine the Git repository root.") from error
|
|
2260
|
+
|
|
2261
|
+
return Path(git_root_bytes.strip().decode("utf-8")).resolve()
|
|
2262
|
+
|
|
2263
|
+
|
|
557
2264
|
def get_git_repo_img_destination() -> Path:
|
|
558
2265
|
"""
|
|
559
2266
|
Get the destination directory for a Git repository.
|
|
560
2267
|
|
|
561
2268
|
Returns:
|
|
562
2269
|
The destination directory for a Git repository.
|
|
2270
|
+
|
|
2271
|
+
Raises:
|
|
2272
|
+
GitError: If git root cannot be determined.
|
|
2273
|
+
SecurityError: If directory creation fails due to security violation.
|
|
563
2274
|
"""
|
|
564
|
-
|
|
565
|
-
git_root_str = (
|
|
566
|
-
subprocess.run(
|
|
567
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
568
|
-
check=True,
|
|
569
|
-
stdout=subprocess.PIPE,
|
|
570
|
-
)
|
|
571
|
-
.stdout.strip()
|
|
572
|
-
.decode("utf-8")
|
|
573
|
-
)
|
|
574
|
-
except subprocess.CalledProcessError:
|
|
575
|
-
sys.exit("Failed to get git root directory.")
|
|
576
|
-
|
|
577
|
-
git_root: Path = Path(git_root_str)
|
|
578
|
-
|
|
579
|
-
if (git_root / "img").exists():
|
|
580
|
-
destination = git_root / "img"
|
|
581
|
-
elif (git_root / "images").exists():
|
|
582
|
-
destination = git_root / "images"
|
|
583
|
-
elif (git_root / "assets" / "img").exists():
|
|
584
|
-
destination = git_root / "assets" / "img"
|
|
585
|
-
elif (git_root / "assets" / "images").exists():
|
|
586
|
-
destination = git_root / "assets" / "images"
|
|
587
|
-
else:
|
|
588
|
-
destination = git_root / "assets" / "images"
|
|
589
|
-
destination.mkdir(parents=True, exist_ok=True)
|
|
2275
|
+
git_root = get_git_root()
|
|
590
2276
|
|
|
2277
|
+
for relative_parts in GIT_IMAGE_DIRECTORY_PRIORITY:
|
|
2278
|
+
candidate = git_root.joinpath(*relative_parts)
|
|
2279
|
+
if candidate.exists():
|
|
2280
|
+
return candidate
|
|
2281
|
+
|
|
2282
|
+
destination = git_root.joinpath(*GIT_IMAGE_DIRECTORY_PRIORITY[-1])
|
|
2283
|
+
# Skip permission hardening for git-tracked directories since they may
|
|
2284
|
+
# be intentionally group-writable in shared repositories (umask 0002)
|
|
2285
|
+
create_directory_safely(destination, mode=0o755, harden_permissions=False)
|
|
591
2286
|
return destination
|
|
592
2287
|
|
|
593
2288
|
|
|
@@ -598,12 +2293,7 @@ def set_auto_stage(auto_stage_enabled: bool) -> None:
|
|
|
598
2293
|
Args:
|
|
599
2294
|
auto_stage_enabled: Whether screenshots are automatically staged when copied to a Git repo.
|
|
600
2295
|
"""
|
|
601
|
-
|
|
602
|
-
config = read_config(config_file_path)
|
|
603
|
-
config["auto_stage_enabled"] = auto_stage_enabled
|
|
604
|
-
|
|
605
|
-
with open(config_file_path, "w", encoding="UTF-8") as file:
|
|
606
|
-
json.dump(config, file, indent=4)
|
|
2296
|
+
update_config_field("auto_stage_enabled", auto_stage_enabled)
|
|
607
2297
|
|
|
608
2298
|
|
|
609
2299
|
def set_default_output_format(output_format: str) -> None:
|
|
@@ -612,77 +2302,152 @@ def set_default_output_format(output_format: str) -> None:
|
|
|
612
2302
|
|
|
613
2303
|
Args:
|
|
614
2304
|
output_format: The default output format.
|
|
2305
|
+
|
|
2306
|
+
Raises:
|
|
2307
|
+
ValidationError: If the output format is not valid.
|
|
615
2308
|
"""
|
|
616
|
-
if output_format.casefold() not in
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
2309
|
+
if output_format.casefold() not in VALID_OUTPUT_FORMATS:
|
|
2310
|
+
valid_options = ", ".join(VALID_OUTPUT_FORMATS)
|
|
2311
|
+
suggestion = suggest_format(output_format, list(VALID_OUTPUT_FORMATS))
|
|
2312
|
+
message = f"Invalid `--output-style`: {output_format}. Use one of: {valid_options}."
|
|
2313
|
+
if suggestion:
|
|
2314
|
+
message = f"{message} {suggestion}"
|
|
2315
|
+
raise ValidationError(message)
|
|
622
2316
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
2317
|
+
_write_config_field("default_output_format", output_format.casefold())
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
def set_default_convert_to(convert_format: str | None) -> None:
|
|
2321
|
+
"""
|
|
2322
|
+
Set the default image conversion format.
|
|
2323
|
+
|
|
2324
|
+
Args:
|
|
2325
|
+
convert_format: The default conversion format (png, jpg/jpeg, webp, gif, or None).
|
|
2326
|
+
|
|
2327
|
+
Raises:
|
|
2328
|
+
ValidationError: If the conversion format is not valid.
|
|
2329
|
+
"""
|
|
2330
|
+
try:
|
|
2331
|
+
normalized_convert_format = normalize_default_convert_to(convert_format)
|
|
2332
|
+
except (TypeError, ValueError) as error:
|
|
2333
|
+
raise ValidationError(str(error)) from error
|
|
626
2334
|
|
|
627
|
-
|
|
628
|
-
json.dump(config, file, indent=4)
|
|
2335
|
+
_write_config_field("default_convert_to", normalized_convert_format)
|
|
629
2336
|
|
|
630
2337
|
|
|
631
2338
|
@wslshot.command()
|
|
632
|
-
@click.option(
|
|
633
|
-
"--source", "-s", help="Specify the default source directory for this operation."
|
|
634
|
-
)
|
|
2339
|
+
@click.option("--source", "-s", help="Default source directory used by `wslshot fetch`.")
|
|
635
2340
|
@click.option(
|
|
636
2341
|
"--destination",
|
|
637
2342
|
"-d",
|
|
638
|
-
help="
|
|
2343
|
+
help="Default destination directory used by `wslshot fetch`.",
|
|
639
2344
|
)
|
|
640
2345
|
@click.option(
|
|
641
2346
|
"--auto-stage-enabled",
|
|
642
2347
|
type=bool,
|
|
643
|
-
help=
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
2348
|
+
help="Automatically run `git add` on copied screenshots when in a Git repo.",
|
|
2349
|
+
)
|
|
2350
|
+
@click.option(
|
|
2351
|
+
"--output-style",
|
|
2352
|
+
"output_format",
|
|
2353
|
+
help=f"Default output style for printed paths ({OUTPUT_FORMATS_HELP}).",
|
|
647
2354
|
)
|
|
648
2355
|
@click.option(
|
|
649
|
-
"--
|
|
650
|
-
"-
|
|
651
|
-
|
|
2356
|
+
"--convert-to",
|
|
2357
|
+
"-c",
|
|
2358
|
+
type=click.Choice(list(VALID_CONVERT_FORMATS), case_sensitive=False),
|
|
2359
|
+
help="Default format to convert to after copying (png, jpg/jpeg, webp, gif).",
|
|
652
2360
|
)
|
|
653
|
-
def configure(source, destination, auto_stage_enabled, output_format):
|
|
2361
|
+
def configure(source, destination, auto_stage_enabled, output_format, convert_to):
|
|
2362
|
+
"""
|
|
2363
|
+
Set defaults for `wslshot fetch` (paths, output style, conversion, and Git auto-staging).
|
|
2364
|
+
|
|
2365
|
+
Run with no options to configure interactively.
|
|
2366
|
+
|
|
2367
|
+
\b
|
|
2368
|
+
Examples:
|
|
2369
|
+
wslshot configure
|
|
2370
|
+
wslshot configure --source "<...>/Screenshots"
|
|
2371
|
+
wslshot configure --destination "<...>/img"
|
|
2372
|
+
wslshot configure --output-style text
|
|
654
2373
|
"""
|
|
655
|
-
|
|
2374
|
+
# When no options are specified, ask the user for their preferences.
|
|
2375
|
+
if all(x is None for x in (source, destination, auto_stage_enabled, output_format, convert_to)):
|
|
2376
|
+
write_config(get_config_file_path_or_exit())
|
|
2377
|
+
return
|
|
656
2378
|
|
|
657
|
-
|
|
2379
|
+
# Otherwise, set the specified options.
|
|
2380
|
+
try:
|
|
2381
|
+
if source:
|
|
2382
|
+
set_default_source(source)
|
|
658
2383
|
|
|
659
|
-
|
|
2384
|
+
if destination:
|
|
2385
|
+
set_default_destination(destination)
|
|
660
2386
|
|
|
661
|
-
|
|
2387
|
+
if auto_stage_enabled is not None:
|
|
2388
|
+
set_auto_stage(auto_stage_enabled)
|
|
662
2389
|
|
|
663
|
-
|
|
2390
|
+
if output_format:
|
|
2391
|
+
set_default_output_format(output_format)
|
|
664
2392
|
|
|
665
|
-
|
|
2393
|
+
if convert_to is not None:
|
|
2394
|
+
set_default_convert_to(convert_to)
|
|
2395
|
+
except SecurityError as error:
|
|
2396
|
+
click.echo(f"{SECURITY_ERROR_PREFIX} {error}", err=True)
|
|
2397
|
+
sys.exit(1)
|
|
2398
|
+
except (ConfigurationError, ValidationError) as error:
|
|
2399
|
+
click.secho(f"Error: {error}", fg="red", err=True)
|
|
2400
|
+
sys.exit(1)
|
|
666
2401
|
|
|
667
|
-
The source directory must be a shared folder between Windows and your Linux VM:
|
|
668
2402
|
|
|
669
|
-
|
|
2403
|
+
@wslshot.command(name="migrate-config")
|
|
2404
|
+
@click.option(
|
|
2405
|
+
"--dry-run",
|
|
2406
|
+
is_flag=True,
|
|
2407
|
+
default=False,
|
|
2408
|
+
help="Show what would change without writing.",
|
|
2409
|
+
)
|
|
2410
|
+
def migrate_config_cmd(dry_run):
|
|
2411
|
+
"""
|
|
2412
|
+
Migrate older config values to the current names (for example, `plain_text` to `text`).
|
|
670
2413
|
|
|
671
|
-
|
|
2414
|
+
\b
|
|
2415
|
+
Examples:
|
|
2416
|
+
wslshot migrate-config --dry-run
|
|
2417
|
+
wslshot migrate-config
|
|
672
2418
|
"""
|
|
673
|
-
|
|
674
|
-
if not any((source, destination, auto_stage_enabled, output_format)):
|
|
675
|
-
write_config(get_config_file_path())
|
|
2419
|
+
config_path = get_config_file_path_or_exit(create_if_missing=False)
|
|
676
2420
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
2421
|
+
if not config_path.exists():
|
|
2422
|
+
click.secho("Nothing to migrate: config file not found.", fg="yellow", err=True)
|
|
2423
|
+
click.echo("Hint: Create one with `wslshot configure`.", err=True)
|
|
2424
|
+
sys.exit(0)
|
|
680
2425
|
|
|
681
|
-
|
|
682
|
-
|
|
2426
|
+
click.echo(f"Config file: {sanitize_path_for_error(config_path)}")
|
|
2427
|
+
click.echo()
|
|
2428
|
+
|
|
2429
|
+
result = migrate_config(config_path, dry_run=dry_run)
|
|
2430
|
+
|
|
2431
|
+
if "error" in result:
|
|
2432
|
+
click.secho(f"Error: {result['error']}", fg="red", err=True)
|
|
2433
|
+
sys.exit(1)
|
|
683
2434
|
|
|
684
|
-
if
|
|
685
|
-
|
|
2435
|
+
if not result["changes"]:
|
|
2436
|
+
click.secho("Config is up to date. No migration needed.", fg="green")
|
|
2437
|
+
sys.exit(0)
|
|
686
2438
|
|
|
687
|
-
|
|
688
|
-
|
|
2439
|
+
# Show changes
|
|
2440
|
+
if dry_run:
|
|
2441
|
+
click.secho("Would change:", fg="yellow")
|
|
2442
|
+
else:
|
|
2443
|
+
click.secho("Changed:", fg="green")
|
|
2444
|
+
|
|
2445
|
+
for change in result["changes"]:
|
|
2446
|
+
click.echo(f" - {change}")
|
|
2447
|
+
|
|
2448
|
+
if dry_run:
|
|
2449
|
+
click.echo()
|
|
2450
|
+
click.echo("Hint: Re-run without `--dry-run` to apply these changes.")
|
|
2451
|
+
else:
|
|
2452
|
+
click.echo()
|
|
2453
|
+
click.secho("Migration complete.", fg="green")
|