stateman 2.0.0__tar.gz → 2.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stateman
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: This module provides tools to track changes in the content of a directory and create compact binary patches that can be used to synchronize another copy of that directory.
5
5
  License: MIT
6
6
  Author: vpuhoff
@@ -61,6 +61,9 @@ Starting with v2.0, patches are safe to apply even when they come from an untrus
61
61
 
62
62
  ## Changelog
63
63
 
64
+ ### 2.0.1
65
+ * Fixed #4: a failure mid-extraction (corrupted ZIP stream, hash mismatch) could leave a partially written file in the target directory after rollback. Files are now extracted to a temporary file, hash-verified, and atomically moved into place (`os.replace`), so a partial file can never appear at the target path.
66
+
64
67
  ### 2.0.0
65
68
  * **Breaking:** `ApplyPatch` now raises `PatchVerificationError` when the final state hash does not match the patch target state (previously it printed a warning and returned `True`), and when a file extracted from the patch fails its hash check.
66
69
  * **Breaking:** default hash algorithm switched from MD5 to SHA-256. Legacy v1 (MD5) patches are still applied correctly.
@@ -44,6 +44,9 @@ Starting with v2.0, patches are safe to apply even when they come from an untrus
44
44
 
45
45
  ## Changelog
46
46
 
47
+ ### 2.0.1
48
+ * Fixed #4: a failure mid-extraction (corrupted ZIP stream, hash mismatch) could leave a partially written file in the target directory after rollback. Files are now extracted to a temporary file, hash-verified, and atomically moved into place (`os.replace`), so a partial file can never appear at the target path.
49
+
47
50
  ### 2.0.0
48
51
  * **Breaking:** `ApplyPatch` now raises `PatchVerificationError` when the final state hash does not match the patch target state (previously it printed a warning and returned `True`), and when a file extracted from the patch fails its hash check.
49
52
  * **Breaking:** default hash algorithm switched from MD5 to SHA-256. Legacy v1 (MD5) patches are still applied correctly.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stateman"
3
- version = "2.0.0"
3
+ version = "2.0.1"
4
4
  description = "This module provides tools to track changes in the content of a directory and create compact binary patches that can be used to synchronize another copy of that directory."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -387,6 +387,10 @@ def ApplyPatch(target, patch_file, exclude=None,
387
387
  backed_up = {}
388
388
  created = []
389
389
  total_extracted = 0
390
+ # Current umask, used to give extracted files the same permissions
391
+ # they would get from a plain open(path, "wb") (mkstemp uses 0600).
392
+ umask = os.umask(0)
393
+ os.umask(umask)
390
394
 
391
395
  def _rollback():
392
396
  """Restore the target directory to its pre-patch state."""
@@ -442,43 +446,58 @@ def ApplyPatch(target, patch_file, exclude=None,
442
446
  f"({member.file_size} / {member.compress_size}), possible zip bomb."
443
447
  )
444
448
 
445
- # Extract manually to the validated path (never trust arcname
446
- # resolution), enforcing the actual byte count while streaming.
447
- written = 0
448
- with patch.open(member) as src, open(target_path, "wb") as dst:
449
- while True:
450
- chunk = src.read(_CHUNK_SIZE)
451
- if not chunk:
452
- break
453
- written += len(chunk)
454
- if written > max_member_size:
455
- raise PatchVerificationError(
456
- f"Archive member '{filename}' expanded beyond the "
457
- f"per-member limit of {max_member_size} bytes."
458
- )
459
- dst.write(chunk)
460
- total_extracted += written
461
- if total_extracted > max_total_size:
462
- raise PatchVerificationError(
463
- f"Patch expanded beyond the total size limit of {max_total_size} bytes."
464
- )
449
+ # Extract to a temporary file in the same directory, verify its
450
+ # hash, and only then atomically move it into place. A failure
451
+ # at any point (corrupted stream, hash mismatch) can never
452
+ # leave a partially written file at the target path (issue #4).
453
+ fd, tmp_path = tempfile.mkstemp(prefix=".stateman-tmp-", dir=target_file_dir)
454
+ os.close(fd)
455
+ try:
456
+ written = 0
457
+ with patch.open(member) as src, open(tmp_path, "wb") as dst:
458
+ while True:
459
+ chunk = src.read(_CHUNK_SIZE)
460
+ if not chunk:
461
+ break
462
+ written += len(chunk)
463
+ if written > max_member_size:
464
+ raise PatchVerificationError(
465
+ f"Archive member '{filename}' expanded beyond the "
466
+ f"per-member limit of {max_member_size} bytes."
467
+ )
468
+ dst.write(chunk)
469
+ total_extracted += written
470
+ if total_extracted > max_total_size:
471
+ raise PatchVerificationError(
472
+ f"Patch expanded beyond the total size limit of {max_total_size} bytes."
473
+ )
474
+
475
+ # Verify the hash of the extracted file before moving it into place
476
+ extracted_hash = get_hash(tmp_path, algo)
477
+ expected_hash = expected_hashes.get(filename)
478
+ if not expected_hash:
479
+ raise PatchVerificationError(
480
+ f"No expected hash found in patch metadata for {filename}."
481
+ )
482
+ if extracted_hash != expected_hash:
483
+ raise PatchVerificationError(
484
+ f"Hash mismatch for extracted file {target_path}! "
485
+ f"Expected {expected_hash}, got {extracted_hash}. Patch is corrupted."
486
+ )
487
+
488
+ os.chmod(tmp_path, 0o666 & ~umask) # mkstemp creates 0600
489
+ os.replace(tmp_path, target_path) # atomic on POSIX and Windows
490
+ finally:
491
+ # Clean up the temp file if anything failed before the replace
492
+ if os.path.exists(tmp_path):
493
+ try:
494
+ os.remove(tmp_path)
495
+ except OSError:
496
+ pass
465
497
 
466
498
  if filename not in backed_up:
467
499
  created.append(filename)
468
500
 
469
- # Verify the hash of the extracted file
470
- extracted_hash = get_hash(target_path, algo)
471
- expected_hash = expected_hashes.get(filename)
472
- if not expected_hash:
473
- raise PatchVerificationError(
474
- f"No expected hash found in patch metadata for {filename}."
475
- )
476
- if extracted_hash != expected_hash:
477
- raise PatchVerificationError(
478
- f"Hash mismatch for extracted file {target_path}! "
479
- f"Expected {expected_hash}, got {extracted_hash}. Patch is corrupted."
480
- )
481
-
482
501
  action = "+" if filename in added else "*"
483
502
  print(f"{action} Extracted: {target_path}")
484
503
 
File without changes