wslshot 0.0.12__py3-none-any.whl → 0.1.1__py3-none-any.whl

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