wslshot 0.1.0__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
@@ -17,7 +17,6 @@ Features:
17
17
  For detailed usage instructions, use 'wslshot --help' or 'wslshot [command] --help'.
18
18
  """
19
19
 
20
- import heapq
21
20
  import json
22
21
  import os
23
22
  import shutil
@@ -272,9 +271,10 @@ def _next_available_backup_path(path: Path, *, suffix: str) -> Path:
272
271
  def _backup_corrupted_file_or_warn(config_file_path: Path) -> None:
273
272
  backup_path: Path | None = None
274
273
  try:
275
- backup_path = _next_available_backup_path(config_file_path, suffix=".corrupted")
276
- config_file_path.replace(backup_path)
277
- except OSError as backup_error:
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
278
  sanitized = sanitize_error_message(
279
279
  str(backup_error),
280
280
  (config_file_path, backup_path) if backup_path is not None else (config_file_path,),
@@ -352,24 +352,26 @@ def write_config_safely(config_file_path: Path, config_data: dict[str, object])
352
352
  """
353
353
  Write configuration data while enforcing secure permissions.
354
354
 
355
- Rejects symlinked config paths to prevent privilege escalation via symlink swaps.
356
- Attempts to fix insecure permissions on existing files (best-effort); if chmod
357
- fails, the atomic write is still attempted since it creates a fresh file with
358
- correct permissions.
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.
359
362
 
360
363
  Args:
361
364
  config_file_path: Path to config file
362
365
  config_data: Configuration dictionary to write
363
366
 
364
367
  Raises:
365
- SecurityError: If the config path is a symlink
368
+ SecurityError: If the resolved config path is invalid
366
369
  OSError: If the atomic write fails
367
370
  """
368
- if config_file_path.is_symlink():
369
- raise SecurityError("Config file is a symlink; refusing to write for safety.")
371
+ config_data_path = resolve_config_data_path(config_file_path)
370
372
 
371
- if config_file_path.exists():
372
- current_perms = config_file_path.stat().st_mode & FILE_PERMISSION_MASK
373
+ if config_data_path.exists():
374
+ current_perms = config_data_path.stat().st_mode & FILE_PERMISSION_MASK
373
375
  if current_perms != CONFIG_FILE_PERMISSIONS:
374
376
  click.echo(
375
377
  f"{WARNING_PREFIX} Config file permissions were too open ({oct(current_perms)}). "
@@ -377,18 +379,39 @@ def write_config_safely(config_file_path: Path, config_data: dict[str, object])
377
379
  err=True,
378
380
  )
379
381
  try:
380
- config_file_path.chmod(CONFIG_FILE_PERMISSIONS)
382
+ config_data_path.chmod(CONFIG_FILE_PERMISSIONS)
381
383
  except OSError as error:
382
384
  # Best-effort: warn but proceed with atomic write
383
385
  # The atomic replace will create a new file with correct permissions
384
- sanitized = sanitize_error_message(str(error), (config_file_path,))
386
+ sanitized = sanitize_error_message(str(error), (config_data_path,))
385
387
  click.echo(
386
388
  f"{WARNING_PREFIX} Could not fix permissions ({sanitized}); "
387
389
  "atomic write will replace with secure file.",
388
390
  err=True,
389
391
  )
390
392
 
391
- atomic_write_json(config_file_path, config_data, mode=CONFIG_FILE_PERMISSIONS)
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
392
415
 
393
416
 
394
417
  def write_config_or_exit(config_file_path: Path, config_data: dict[str, object]) -> None:
@@ -1268,8 +1291,8 @@ def get_screenshots(
1268
1291
  # Get the most recent screenshot(s) from the source directory.
1269
1292
  try:
1270
1293
  # Use scandir for efficient directory iteration (single directory scan)
1271
- # Stat each file exactly once and cache the result
1272
- file_stats = []
1294
+ # Keep metadata so we can validate newest candidates first.
1295
+ candidates: list[tuple[float, Path, int]] = []
1273
1296
  with os.scandir(source) as entries:
1274
1297
  for entry in entries:
1275
1298
  # Check extension before stat (cheap filter)
@@ -1285,29 +1308,34 @@ def get_screenshots(
1285
1308
  )
1286
1309
  continue
1287
1310
  # Stat once and check if it's a regular file
1288
- stat_result = file_path.stat()
1311
+ stat_result = entry.stat()
1289
1312
  if S_ISREG(stat_result.st_mode):
1290
- # SECURITY: Validate file content, not just extension (PERSO-193 - CWE-434)
1291
- try:
1292
- validate_image_file(
1293
- file_path,
1294
- max_size_bytes=max_file_size_bytes,
1295
- file_size=stat_result.st_size,
1296
- )
1297
- file_stats.append((file_path, stat_result.st_mtime))
1298
- except ValueError as e:
1299
- # Graceful degradation: skip invalid files with warning
1300
- click.echo(
1301
- f"{WARNING_PREFIX} Skipping invalid image file: {e}",
1302
- err=True,
1303
- )
1313
+ candidates.append(
1314
+ (stat_result.st_mtime, file_path, stat_result.st_size)
1315
+ )
1304
1316
  except OSError:
1305
1317
  # Skip files we can't stat (broken symlinks, permission issues, etc.)
1306
1318
  pass
1307
1319
 
1308
- # Use heapq for efficient partial sorting: O(N log count) instead of O(N log N)
1309
- top_files = heapq.nlargest(count, file_stats, key=lambda x: x[1])
1310
- screenshots = [file for file, _ in top_files]
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
+ )
1311
1339
 
1312
1340
  sanitized_source = sanitize_path_for_error(source)
1313
1341
 
@@ -1640,8 +1668,9 @@ def get_config_file_path(*, create_if_missing: bool = True) -> Path:
1640
1668
  config_dir = Path.home() / CONFIG_DIR_RELATIVE
1641
1669
  config_file_path = config_dir / CONFIG_FILE_NAME
1642
1670
 
1671
+ # Validate symlink target shape early for clearer errors.
1643
1672
  if config_file_path.is_symlink():
1644
- raise SecurityError("Config file is a symlink; refusing to use it.")
1673
+ resolve_config_data_path(config_file_path)
1645
1674
 
1646
1675
  if create_if_missing:
1647
1676
  create_directory_safely(config_file_path.parent, mode=CONFIG_DIR_PERMISSIONS)
@@ -1660,12 +1689,13 @@ def get_config_file_path_or_exit(*, create_if_missing: bool = True) -> Path:
1660
1689
  except SecurityError as error:
1661
1690
  click.echo(f"{SECURITY_ERROR_PREFIX} {error}", err=True)
1662
1691
  error_msg = str(error).lower()
1663
- if "symlink" in error_msg:
1692
+ if "symlink loop" in error_msg:
1664
1693
  click.echo(
1665
- "Hint: Remove the symlink at ~/.config/wslshot/config.json, then rerun this "
1666
- "command.",
1694
+ "Hint: Fix the symlink at ~/.config/wslshot/config.json, then rerun this command.",
1667
1695
  err=True,
1668
1696
  )
1697
+ elif "directory" in error_msg:
1698
+ click.echo("Hint: Point ~/.config/wslshot/config.json to a regular file.", err=True)
1669
1699
  elif "different user" in error_msg:
1670
1700
  click.echo("Hint: Check directory ownership or use a different path.", err=True)
1671
1701
  sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wslshot
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Copy Windows screenshots into WSL for easy reuse.
5
5
  Author: Sébastien De Revière
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,7 @@
1
+ wslshot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ wslshot/cli.py,sha256=4ydFsJ-8n0clW0Xrxr1gwzoNKrAk_AePtx7ZoM_qnGg,88697
3
+ wslshot/exceptions.py,sha256=i28RvmvichlKkRzWjkbU2LsMS9dliENz8BeWAk-KdME,525
4
+ wslshot-0.1.1.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
5
+ wslshot-0.1.1.dist-info/entry_points.txt,sha256=i9-PCDJjxAkeZCNOe98GOxNBUVOLPuOr9Vb022lDmeY,49
6
+ wslshot-0.1.1.dist-info/METADATA,sha256=iYBOdiYnVgsqFaSgE781wAgO3TtgBrVonKj09P7kog0,11331
7
+ wslshot-0.1.1.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- wslshot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- wslshot/cli.py,sha256=74y-a8m6RusAZRemMpNYfP3guIM2EEIRH06LmDLjdV4,87695
3
- wslshot/exceptions.py,sha256=i28RvmvichlKkRzWjkbU2LsMS9dliENz8BeWAk-KdME,525
4
- wslshot-0.1.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
5
- wslshot-0.1.0.dist-info/entry_points.txt,sha256=i9-PCDJjxAkeZCNOe98GOxNBUVOLPuOr9Vb022lDmeY,49
6
- wslshot-0.1.0.dist-info/METADATA,sha256=R-GfJAUr94zpV5tE-6LzAdZcMzutrCA6bWzc6o0jkCE,11331
7
- wslshot-0.1.0.dist-info/RECORD,,