wslshot 0.0.11__py3-none-any.whl → 0.1.0__py3-none-any.whl

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