wslshot 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0 → wslshot-0.1.1}/PKG-INFO +1 -1
- {wslshot-0.1.0 → wslshot-0.1.1}/pyproject.toml +1 -1
- {wslshot-0.1.0 → wslshot-0.1.1}/wslshot/cli.py +70 -40
- {wslshot-0.1.0 → wslshot-0.1.1}/README.md +0 -0
- {wslshot-0.1.0 → wslshot-0.1.1}/wslshot/__init__.py +0 -0
- {wslshot-0.1.0 → wslshot-0.1.1}/wslshot/exceptions.py +0 -0
|
@@ -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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
368
|
+
SecurityError: If the resolved config path is invalid
|
|
366
369
|
OSError: If the atomic write fails
|
|
367
370
|
"""
|
|
368
|
-
|
|
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
|
|
372
|
-
current_perms =
|
|
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
|
-
|
|
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), (
|
|
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(
|
|
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
|
-
#
|
|
1272
|
-
|
|
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 =
|
|
1311
|
+
stat_result = entry.stat()
|
|
1289
1312
|
if S_ISREG(stat_result.st_mode):
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
#
|
|
1309
|
-
|
|
1310
|
-
screenshots = [
|
|
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
|
-
|
|
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:
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|