stateman 1.3.1__tar.gz → 2.0.0__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,10 +1,11 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: stateman
3
- Version: 1.3.1
3
+ Version: 2.0.0
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
7
7
  Author-email: vpuhoff@live.ru
8
+ Requires-Python: >=3.9
8
9
  Classifier: Programming Language :: Python :: 3
9
10
  Classifier: License :: OSI Approved :: MIT License
10
11
  Classifier: Operating System :: OS Independent
@@ -26,7 +27,7 @@ Imagine you have a large directory with files (e.g., installed software, game as
26
27
 
27
28
  `stateman` solves this problem by allowing you to:
28
29
 
29
- 1. **Capture the state** of a directory at a specific point in time (list of files and their MD5 checksums).
30
+ 1. **Capture the state** of a directory at a specific point in time (list of files and their SHA-256 checksums).
30
31
  2. **Compare two states** (e.g., before and after an update) to determine which files were added, removed, or modified.
31
32
  3. **Create a binary patch** (a ZIP archive) containing only the added and modified files, along with metadata about the changes.
32
33
  4. **Apply the patch** to another copy of the directory (which is in the initial state) to bring it to the updated state.
@@ -47,6 +48,33 @@ The basic workflow is:
47
48
 
48
49
  * Wiki: [https://deepwiki.com/vpuhoff/stateman](https://deepwiki.com/vpuhoff/stateman/1-overview)
49
50
 
51
+ ## Security (v2.0)
52
+
53
+ Starting with v2.0, patches are safe to apply even when they come from an untrusted source:
54
+
55
+ * **SHA-256** file and state hashes (v1 patches used MD5; old patches are still supported for reading).
56
+ * **Path traversal protection** — every path from patch metadata is validated before anything touches the disk: absolute paths, backslashes, drive letters and `..` components are rejected (`UnsafePatchPathError`).
57
+ * **Per-file integrity verification** — each extracted file is checked against its expected hash before proceeding.
58
+ * **Transactional apply with rollback** — if any step fails (corrupted archive member, hash mismatch, final state mismatch), all changes are rolled back and the target directory is restored to its original state. `ApplyPatch` raises an exception instead of silently returning `True`.
59
+ * **Compressed patches** — archives are written with `ZIP_DEFLATED` instead of stored.
60
+ * **Zip-bomb protection** — each archive member is checked against its declared uncompressed size, compression ratio (default max 1000x), a per-member cap (4 GiB) and a cumulative cap (16 GiB), both before and during streaming extraction. All limits are overridable via `ApplyPatch(...)` arguments.
61
+
62
+ ## Changelog
63
+
64
+ ### 2.0.0
65
+ * **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
+ * **Breaking:** default hash algorithm switched from MD5 to SHA-256. Legacy v1 (MD5) patches are still applied correctly.
67
+ * Path traversal protection for all paths taken from patch metadata (`UnsafePatchPathError`).
68
+ * `ApplyPatch` is transactional: failed patches are rolled back automatically.
69
+ * Patches are compressed with DEFLATE.
70
+ * Zip-bomb protection: per-member size cap, cumulative size cap and compression-ratio limit (configurable via `ApplyPatch` arguments).
71
+ * New exceptions: `PatchError`, `PatchVerificationError`, `UnsafePatchPathError`, `PatchFormatError`.
72
+ * Removed the unused `CheckGitRepo` helper and the stray `keylocker` console script from `pyproject.toml`.
73
+ * CI matrix: Linux + Windows, Python 3.9–3.13.
74
+
75
+ ### 1.3.1
76
+ * Previous stable release.
77
+
50
78
  ![Alt](https://repobeats.axiom.co/api/embed/f5781b0c7b823dcf233cfcdd37ae992cdc054ae5.svg "Repobeats analytics image")
51
79
 
52
80
  ## Usage Example:
@@ -10,7 +10,7 @@ Imagine you have a large directory with files (e.g., installed software, game as
10
10
 
11
11
  `stateman` solves this problem by allowing you to:
12
12
 
13
- 1. **Capture the state** of a directory at a specific point in time (list of files and their MD5 checksums).
13
+ 1. **Capture the state** of a directory at a specific point in time (list of files and their SHA-256 checksums).
14
14
  2. **Compare two states** (e.g., before and after an update) to determine which files were added, removed, or modified.
15
15
  3. **Create a binary patch** (a ZIP archive) containing only the added and modified files, along with metadata about the changes.
16
16
  4. **Apply the patch** to another copy of the directory (which is in the initial state) to bring it to the updated state.
@@ -31,6 +31,33 @@ The basic workflow is:
31
31
 
32
32
  * Wiki: [https://deepwiki.com/vpuhoff/stateman](https://deepwiki.com/vpuhoff/stateman/1-overview)
33
33
 
34
+ ## Security (v2.0)
35
+
36
+ Starting with v2.0, patches are safe to apply even when they come from an untrusted source:
37
+
38
+ * **SHA-256** file and state hashes (v1 patches used MD5; old patches are still supported for reading).
39
+ * **Path traversal protection** — every path from patch metadata is validated before anything touches the disk: absolute paths, backslashes, drive letters and `..` components are rejected (`UnsafePatchPathError`).
40
+ * **Per-file integrity verification** — each extracted file is checked against its expected hash before proceeding.
41
+ * **Transactional apply with rollback** — if any step fails (corrupted archive member, hash mismatch, final state mismatch), all changes are rolled back and the target directory is restored to its original state. `ApplyPatch` raises an exception instead of silently returning `True`.
42
+ * **Compressed patches** — archives are written with `ZIP_DEFLATED` instead of stored.
43
+ * **Zip-bomb protection** — each archive member is checked against its declared uncompressed size, compression ratio (default max 1000x), a per-member cap (4 GiB) and a cumulative cap (16 GiB), both before and during streaming extraction. All limits are overridable via `ApplyPatch(...)` arguments.
44
+
45
+ ## Changelog
46
+
47
+ ### 2.0.0
48
+ * **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
+ * **Breaking:** default hash algorithm switched from MD5 to SHA-256. Legacy v1 (MD5) patches are still applied correctly.
50
+ * Path traversal protection for all paths taken from patch metadata (`UnsafePatchPathError`).
51
+ * `ApplyPatch` is transactional: failed patches are rolled back automatically.
52
+ * Patches are compressed with DEFLATE.
53
+ * Zip-bomb protection: per-member size cap, cumulative size cap and compression-ratio limit (configurable via `ApplyPatch` arguments).
54
+ * New exceptions: `PatchError`, `PatchVerificationError`, `UnsafePatchPathError`, `PatchFormatError`.
55
+ * Removed the unused `CheckGitRepo` helper and the stray `keylocker` console script from `pyproject.toml`.
56
+ * CI matrix: Linux + Windows, Python 3.9–3.13.
57
+
58
+ ### 1.3.1
59
+ * Previous stable release.
60
+
34
61
  ![Alt](https://repobeats.axiom.co/api/embed/f5781b0c7b823dcf233cfcdd37ae992cdc054ae5.svg "Repobeats analytics image")
35
62
 
36
63
  ## Usage Example:
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "stateman"
3
- version = "1.3.1" # Убедитесь, что версия актуальна
3
+ version = "2.0.0"
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
- license = { text = "MIT" }
6
+ license = { text = "MIT" }
7
7
  authors = [
8
8
  { name = "vpuhoff", email = "vpuhoff@live.ru" }
9
9
  ]
@@ -11,15 +11,13 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3",
12
12
  "License :: OSI Approved :: MIT License",
13
13
  "Operating System :: OS Independent",
14
- "Development Status :: 4 - Beta", # Примерный статус
14
+ "Development Status :: 4 - Beta",
15
15
  "Intended Audience :: Developers",
16
16
  "Topic :: Software Development :: Libraries :: Python Modules",
17
17
  "Topic :: Utilities",
18
18
  ]
19
- dependencies = []
20
-
21
- [project.scripts]
22
- keylocker = "entry:main"
19
+ dependencies = []
20
+ requires-python = ">=3.9"
23
21
 
24
22
  [build-system]
25
23
  requires = ["poetry-core>=1.0.0"]
@@ -0,0 +1,507 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from collections import OrderedDict
7
+ from pathlib import Path, PurePosixPath
8
+ from zipfile import ZIP_DEFLATED, BadZipFile, ZipFile
9
+
10
+ # Hash algorithm used for new patches. Old (v1) patches used MD5 and are
11
+ # still readable by ApplyPatch via the legacy "md5" metadata format.
12
+ DEFAULT_HASH_ALGO = "sha256"
13
+ SUPPORTED_HASH_ALGOS = ("sha256", "md5")
14
+
15
+ # Size of chunks used when hashing / copying files.
16
+ _CHUNK_SIZE = 64 * 1024
17
+
18
+ # Zip-bomb protection defaults for ApplyPatch (can be overridden per call):
19
+ # a single member may not declare/expand to more than 4 GiB, all members
20
+ # together not more than 16 GiB, and the compression ratio is capped at 1000x.
21
+ DEFAULT_MAX_MEMBER_SIZE = 4 * 1024 ** 3
22
+ DEFAULT_MAX_TOTAL_SIZE = 16 * 1024 ** 3
23
+ DEFAULT_MAX_COMPRESSION_RATIO = 1000
24
+
25
+ # Define the path separator for the current OS for unification
26
+ sep = os.path.sep
27
+
28
+
29
+ class PatchError(Exception):
30
+ """Base class for all stateman patch errors."""
31
+
32
+
33
+ class UnsafePatchPathError(PatchError):
34
+ """A path contained in patch metadata is unsafe (escapes the target directory)."""
35
+
36
+
37
+ class PatchVerificationError(PatchError):
38
+ """Patch content or the resulting state failed an integrity check."""
39
+
40
+
41
+ class PatchFormatError(PatchError):
42
+ """The patch file is not a valid stateman patch."""
43
+
44
+
45
+ def get_hash(filename, algo=DEFAULT_HASH_ALGO):
46
+ """Calculates the hash of a file.
47
+
48
+ Reads the file in chunks for efficient handling of large files.
49
+
50
+ Args:
51
+ filename (str): Path to the file.
52
+ algo (str): Hash algorithm name, e.g. "sha256" (default) or "md5".
53
+
54
+ Returns:
55
+ str: The hex digest of the file, or None if the file could not be read.
56
+ """
57
+ hasher = hashlib.new(algo)
58
+ try:
59
+ with open(filename, "rb") as f:
60
+ for chunk in iter(lambda: f.read(_CHUNK_SIZE), b""):
61
+ hasher.update(chunk)
62
+ except OSError:
63
+ # File disappeared or is unreadable between steps
64
+ return None
65
+ return hasher.hexdigest()
66
+
67
+
68
+ def find_files(folder, exclude=None, algo=DEFAULT_HASH_ALGO):
69
+ """Recursively finds all files in a directory and calculates their hashes.
70
+
71
+ Ignores files/directories whose path contains the `exclude` string.
72
+ Normalizes path separators to '/' for consistency across OS.
73
+
74
+ Args:
75
+ folder (str): The root directory to search.
76
+ exclude (str, optional): A string, paths containing it will be excluded.
77
+ algo (str): Hash algorithm used for file hashes.
78
+
79
+ Yields:
80
+ tuple: (relative_path, hash) for each found file.
81
+ The relative path uses '/' as a separator.
82
+ """
83
+ if not folder.endswith(os.path.sep):
84
+ folder += os.path.sep
85
+
86
+ for root, dirs, files in os.walk(folder):
87
+ if exclude:
88
+ dirs[:] = [d for d in dirs if exclude not in os.path.join(root, d)]
89
+
90
+ for file in files:
91
+ filename = os.path.join(root, file)
92
+ relative_path = filename.replace(folder, '').replace("\\", '/') # Path normalization
93
+
94
+ if exclude and exclude in filename: # Check the full path for exclude
95
+ continue
96
+
97
+ file_hash = get_hash(filename, algo)
98
+ if file_hash: # Ensure the hash was obtained (file wasn't deleted)
99
+ yield relative_path, file_hash
100
+
101
+
102
+ def GetState(folder, exclude=None, algo=DEFAULT_HASH_ALGO):
103
+ """Creates a dictionary representing the state of a directory (file -> hash).
104
+
105
+ Args:
106
+ folder (str): Path to the directory.
107
+ exclude (str, optional): Pattern to exclude files/directories.
108
+ algo (str): Hash algorithm used for file hashes.
109
+
110
+ Returns:
111
+ dict: Relative file paths (with '/' separator) mapped to their hashes.
112
+ """
113
+ return dict(find_files(folder, exclude, algo))
114
+
115
+
116
+ def GetStateHash(state, algo=DEFAULT_HASH_ALGO):
117
+ """Calculates a single hash for the entire directory state.
118
+
119
+ The hash depends on file names and their hashes. Sorting keys ensures
120
+ the same hash for the same state, regardless of file traversal order.
121
+
122
+ Args:
123
+ state (dict): The directory state dictionary (result of GetState).
124
+ algo (str): Hash algorithm used for the state digest.
125
+
126
+ Returns:
127
+ str: The overall hash of the state as a hexadecimal string.
128
+ """
129
+ hasher = hashlib.new(algo)
130
+ ordered = OrderedDict(sorted(state.items()))
131
+ for k, v in ordered.items():
132
+ hasher.update(k.encode('utf-8'))
133
+ hasher.update(v.encode('utf-8'))
134
+ return hasher.hexdigest()
135
+
136
+
137
+ def GetDiff(state1, state2, algo=DEFAULT_HASH_ALGO):
138
+ """Calculates the difference between two directory states.
139
+
140
+ Args:
141
+ state1 (dict): The initial state (file -> hash dictionary).
142
+ state2 (dict): The final state (file -> hash dictionary).
143
+ algo (str): Hash algorithm the states were computed with. It is stored
144
+ in the diff so ApplyPatch can verify states consistently.
145
+
146
+ Returns:
147
+ dict: A dictionary with difference information:
148
+ - 'removed': list[str] - removed files.
149
+ - 'added': list[str] - added files.
150
+ - 'changed': list[str] - changed files (different hashes).
151
+ - 'state': dict - the final state (state2).
152
+ - 'hashes': dict - hashes of added and changed files from state2.
153
+ - 'hash_algo': str - hash algorithm used.
154
+ - 'source_state': str - hash of the initial state (state1).
155
+ - 'target_state': str - hash of the final state (state2).
156
+ """
157
+ keysA = set(state1.keys())
158
+ keysB = set(state2.keys())
159
+
160
+ removed = keysA - keysB # Files present in state1 but not in state2
161
+ added = keysB - keysA # Files present in state2 but not in state1
162
+ keep = keysA.intersection(keysB)
163
+
164
+ changed = [key for key in keep if state1[key] != state2[key]]
165
+
166
+ result = {
167
+ 'removed': list(removed),
168
+ 'added': list(added),
169
+ 'changed': list(changed),
170
+ 'state': state2, # Store the final state in the diff
171
+ 'hashes': {}, # Hashes of files to be included in the patch
172
+ 'hash_algo': algo,
173
+ 'source_state': GetStateHash(state1, algo),
174
+ 'target_state': GetStateHash(state2, algo)
175
+ }
176
+
177
+ allfiles_to_include = set(changed)
178
+ allfiles_to_include.update(added)
179
+ for key in allfiles_to_include:
180
+ result['hashes'][key] = state2[key]
181
+
182
+ return result
183
+
184
+
185
+ def ClearPatch(path):
186
+ """Normalizes path separators to be specific to the current OS.
187
+
188
+ Converts '/' to '\\' on Windows and vice versa on other systems.
189
+ Paths within diffs and patches are always stored with '/'.
190
+
191
+ Args:
192
+ path (str): Path with '/' separators.
193
+
194
+ Returns:
195
+ str: Path with separators specific to the current OS.
196
+ """
197
+ if sep == "\\": # If OS is Windows
198
+ return path.replace("/", "\\")
199
+ else:
200
+ return path.replace("\\", "/")
201
+
202
+
203
+ def safe_join(base, relative_path):
204
+ """Joins `relative_path` (from patch metadata) to `base`, safely.
205
+
206
+ Rejects absolute paths, drive letters, backslashes, null bytes and any
207
+ '..' components, and verifies the resolved result stays inside `base`.
208
+ This prevents path-traversal attacks from untrusted patches.
209
+
210
+ Args:
211
+ base (str): The target directory (trusted).
212
+ relative_path (str): The path from patch metadata (untrusted).
213
+
214
+ Returns:
215
+ str: The safe absolute path of the file inside `base`.
216
+
217
+ Raises:
218
+ UnsafePatchPathError: If the relative path is unsafe.
219
+ """
220
+ if not relative_path or "\x00" in relative_path:
221
+ raise UnsafePatchPathError(f"Empty or invalid path in patch metadata: {relative_path!r}")
222
+ if "\\" in relative_path:
223
+ raise UnsafePatchPathError(f"Backslash in patch path (expected '/'): {relative_path!r}")
224
+
225
+ pure = PurePosixPath(relative_path)
226
+ if pure.is_absolute():
227
+ raise UnsafePatchPathError(f"Absolute path in patch metadata: {relative_path!r}")
228
+ if any(part == ".." for part in pure.parts):
229
+ raise UnsafePatchPathError(f"Path traversal ('..') in patch metadata: {relative_path!r}")
230
+
231
+ base_real = os.path.realpath(base)
232
+ full = os.path.realpath(os.path.join(base_real, *pure.parts))
233
+ # Belt and braces: never escape the base directory.
234
+ try:
235
+ inside = os.path.commonpath([base_real, full]) == base_real
236
+ except ValueError: # e.g. different drives on Windows
237
+ inside = False
238
+ if not inside:
239
+ raise UnsafePatchPathError(f"Path escapes the target directory: {relative_path!r}")
240
+ return full
241
+
242
+
243
+ def CreatePatch(source_folder, patch_file, diff):
244
+ """Creates a ZIP archive (patch) containing the changes.
245
+
246
+ The patch includes metadata (diff information) and the necessary files
247
+ (added and changed), compressed with DEFLATE.
248
+
249
+ Args:
250
+ source_folder (str): The folder from which changed and added files are taken.
251
+ patch_file (str): The filename for the created ZIP patch.
252
+ diff (dict): The difference dictionary obtained from GetDiff.
253
+
254
+ Raises:
255
+ UnsafePatchPathError: If a path in the diff escapes the source folder.
256
+ PatchError: If a required file is missing from the source folder.
257
+ """
258
+ with ZipFile(patch_file, "w", compression=ZIP_DEFLATED) as z:
259
+ z.writestr("metadata.json", data=json.dumps(diff, indent=4, ensure_ascii=False))
260
+
261
+ for file in diff.get('added', []) + diff.get('changed', []):
262
+ source_path = safe_join(source_folder, file) # Also validates the path
263
+ if not os.path.isfile(source_path):
264
+ raise PatchError(f"File listed in diff not found in source folder: {file}")
265
+ z.write(source_path, arcname=file)
266
+
267
+
268
+ def ApplyPatch(target, patch_file, exclude=None,
269
+ max_member_size=DEFAULT_MAX_MEMBER_SIZE,
270
+ max_total_size=DEFAULT_MAX_TOTAL_SIZE,
271
+ max_compression_ratio=DEFAULT_MAX_COMPRESSION_RATIO):
272
+ """Applies a patch to the target directory.
273
+
274
+ Verifies that the current state of the target directory matches the source
275
+ state specified in the patch, deletes, adds, and updates files according
276
+ to the patch metadata, and verifies the final state hash. Every file
277
+ written from the patch is checked against its expected hash.
278
+
279
+ The application is transactional: if any step fails (hash mismatch,
280
+ missing archive member, final state mismatch), all changes made so far
281
+ are rolled back and an exception is raised. The target directory is left
282
+ in its original state.
283
+
284
+ Zip-bomb protection: each archive member is checked against the declared
285
+ uncompressed size, the compression ratio, a per-member cap and a
286
+ cumulative cap before and during extraction.
287
+
288
+ Supports both the current v2 patches (SHA-256, 'hashes' metadata key)
289
+ and legacy v1 patches (MD5, 'md5' metadata key).
290
+
291
+ Args:
292
+ target (str): Path to the target directory where the patch is applied.
293
+ patch_file (str): Path to the ZIP patch file.
294
+ exclude (str, optional): Pattern to exclude files when checking the
295
+ current state of the target directory.
296
+ max_member_size (int): Maximum uncompressed size of a single archive
297
+ member in bytes (default 4 GiB).
298
+ max_total_size (int): Maximum total uncompressed size of all members
299
+ extracted by this patch in bytes (default 16 GiB).
300
+ max_compression_ratio (int): Maximum allowed compression ratio of an
301
+ archive member (default 1000).
302
+
303
+ Returns:
304
+ bool: True if the patch was successfully applied or if the directory
305
+ was already in the target state.
306
+
307
+ Raises:
308
+ FileNotFoundError: If the target directory or patch file does not exist.
309
+ PatchFormatError: If the patch file is invalid (not a ZIP, missing or
310
+ corrupt metadata.json, unsupported hash algorithm).
311
+ UnsafePatchPathError: If a path in patch metadata is unsafe.
312
+ PatchVerificationError: If the current state of the target directory
313
+ does not match the patch source state, if an extracted file
314
+ fails its hash check, or if the final state does not match
315
+ the patch target state.
316
+ PatchError: For other fatal errors during patch application.
317
+ """
318
+ if not os.path.isdir(target):
319
+ raise FileNotFoundError(f"Target directory not found: {target}")
320
+ if not os.path.isfile(patch_file):
321
+ raise FileNotFoundError(f"Patch file not found: {patch_file}")
322
+
323
+ try:
324
+ patch = ZipFile(patch_file, "r")
325
+ except BadZipFile as e:
326
+ raise PatchFormatError(f"Invalid patch file (not a ZIP archive): {patch_file}") from e
327
+
328
+ with patch:
329
+ # Read metadata from the patch
330
+ try:
331
+ with patch.open('metadata.json', 'r') as metadata_file:
332
+ diff = json.loads(metadata_file.read().decode('utf-8'))
333
+ except KeyError:
334
+ raise PatchFormatError("Invalid patch file: metadata.json not found.")
335
+ except (json.JSONDecodeError, UnicodeDecodeError):
336
+ raise PatchFormatError("Invalid patch file: metadata.json is corrupted.")
337
+
338
+ algo = diff.get('hash_algo', 'md5') # v1 patches have no 'hash_algo'
339
+ if algo not in SUPPORTED_HASH_ALGOS:
340
+ raise PatchFormatError(f"Unsupported hash algorithm in patch: {algo}")
341
+
342
+ # v2 patches use 'hashes'; v1 patches used 'md5'
343
+ expected_hashes = diff.get('hashes') or diff.get('md5') or {}
344
+
345
+ removed = list(diff.get('removed', []))
346
+ added = list(diff.get('added', []))
347
+ changed = list(diff.get('changed', []))
348
+ files_to_extract = added + changed
349
+
350
+ print(f"Patch contains: Removed: {len(removed)}, Added: {len(added)}, Changed: {len(changed)}")
351
+
352
+ # Validate every path up front, before touching the file system.
353
+ for filename in removed + files_to_extract:
354
+ safe_join(target, filename)
355
+
356
+ # Get the current state of the target directory
357
+ current_state = GetState(target, exclude, algo=algo)
358
+ state_hash = GetStateHash(current_state, algo)
359
+
360
+ print(f"Current state hash: {state_hash}")
361
+ print(f"Patch source state hash: {diff.get('source_state')}")
362
+ print(f"Patch target state hash: {diff.get('target_state')}")
363
+
364
+ # Check if the patch needs to be applied at all
365
+ if diff.get('target_state') == state_hash:
366
+ print("Target directory is already in the target state. No action needed.")
367
+ return True
368
+
369
+ # Check if the current state matches the patch's source state
370
+ if diff.get('source_state') != state_hash:
371
+ print("Current state:", json.dumps(current_state, indent=2))
372
+ raise PatchVerificationError(
373
+ "The current state of the target directory does not match the source state required by the patch."
374
+ )
375
+
376
+ # --- Applying changes (transactionally) ---
377
+
378
+ # Backup directory (outside the target tree) used for rollback.
379
+ target_parent = os.path.dirname(os.path.abspath(target)) or os.path.curdir
380
+ try:
381
+ backup_dir = tempfile.mkdtemp(prefix=".stateman-backup-", dir=target_parent)
382
+ except OSError:
383
+ backup_dir = tempfile.mkdtemp(prefix="stateman-backup-")
384
+
385
+ # (relative_path -> backup location) for files we replace/delete,
386
+ # and list of files we created (did not exist before).
387
+ backed_up = {}
388
+ created = []
389
+ total_extracted = 0
390
+
391
+ def _rollback():
392
+ """Restore the target directory to its pre-patch state."""
393
+ for rel in created:
394
+ try:
395
+ os.remove(safe_join(target, rel))
396
+ except OSError:
397
+ pass
398
+ for rel, backup_path in backed_up.items():
399
+ try:
400
+ original = safe_join(target, rel)
401
+ os.makedirs(os.path.dirname(original), exist_ok=True)
402
+ shutil.move(backup_path, original)
403
+ except OSError:
404
+ pass
405
+ shutil.rmtree(backup_dir, ignore_errors=True)
406
+
407
+ try:
408
+ print("Applying patch...")
409
+
410
+ # 1. Back up every file that will be deleted or replaced
411
+ for filename in removed + changed:
412
+ original = safe_join(target, filename)
413
+ if os.path.isfile(original):
414
+ backup_path = os.path.join(backup_dir, ClearPatch(filename))
415
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
416
+ shutil.move(original, backup_path)
417
+ backed_up[filename] = backup_path
418
+
419
+ # 2. Extract and verify files (added and changed)
420
+ for filename in files_to_extract:
421
+ target_path = safe_join(target, filename)
422
+ target_file_dir = os.path.dirname(target_path)
423
+ Path(target_file_dir).mkdir(parents=True, exist_ok=True)
424
+
425
+ try:
426
+ member = patch.getinfo(filename)
427
+ except KeyError:
428
+ raise PatchVerificationError(
429
+ f"File '{filename}' listed in patch metadata but not found in the archive."
430
+ )
431
+
432
+ # Zip-bomb guards, checked against the (untrusted) declared
433
+ # sizes before any decompression happens.
434
+ if member.file_size > max_member_size:
435
+ raise PatchVerificationError(
436
+ f"Archive member '{filename}' declares {member.file_size} bytes, "
437
+ f"which exceeds the per-member limit of {max_member_size}."
438
+ )
439
+ if member.compress_size and member.file_size > member.compress_size * max_compression_ratio:
440
+ raise PatchVerificationError(
441
+ f"Archive member '{filename}' has a suspicious compression ratio "
442
+ f"({member.file_size} / {member.compress_size}), possible zip bomb."
443
+ )
444
+
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
+ )
465
+
466
+ if filename not in backed_up:
467
+ created.append(filename)
468
+
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
+ action = "+" if filename in added else "*"
483
+ print(f"{action} Extracted: {target_path}")
484
+
485
+ # 3. Delete removed files (already backed up in step 1)
486
+ for filename in removed:
487
+ if filename in backed_up: # existed and was moved to backup - done
488
+ print(f"- Removed: {safe_join(target, filename)}")
489
+ else:
490
+ print(f"Warning: File to remove not found (already removed?): {filename}")
491
+
492
+ # 4. Final verification: state after patching must match target_state
493
+ final_state_hash = GetStateHash(GetState(target, exclude, algo=algo), algo)
494
+ if final_state_hash != diff.get('target_state'):
495
+ raise PatchVerificationError(
496
+ f"Final state hash ({final_state_hash}) does not match patch target state hash "
497
+ f"({diff.get('target_state')}). Rolling back."
498
+ )
499
+ except Exception:
500
+ _rollback()
501
+ print("Patch application failed. Changes rolled back, target directory restored.")
502
+ raise
503
+
504
+ # Success: drop the backup
505
+ shutil.rmtree(backup_dir, ignore_errors=True)
506
+ print("Patch applied successfully.")
507
+ return True
@@ -1,371 +0,0 @@
1
- import hashlib
2
- import json
3
- import os
4
- import shutil # Imported but not used directly in this file? Maybe needed for tests or example.
5
- from collections import OrderedDict
6
- from pathlib import Path
7
- from zipfile import ZipFile
8
- from subprocess import Popen, PIPE # Used only in CheckGitRepo
9
-
10
- # Define the path separator for the current OS for unification
11
- sep = os.path.sep
12
-
13
- def get_hash(filename):
14
- """Calculates the MD5 hash of a file.
15
-
16
- Reads the file in chunks for efficient handling of large files.
17
-
18
- Args:
19
- filename (str): Path to the file.
20
-
21
- Returns:
22
- str: The MD5 hash of the file as a hexadecimal string. Returns None if the file is not found.
23
- """
24
- hash_md5 = hashlib.md5()
25
- try:
26
- with open(filename, "rb") as f:
27
- # Read the file in 4096-byte blocks
28
- for chunk in iter(lambda: f.read(4096), b""):
29
- hash_md5.update(chunk)
30
- except FileNotFoundError:
31
- # Handle the case where the file is not found (e.g., deleted between steps)
32
- # Can return None, an empty string, or raise an exception depending on the logic
33
- print(f"Warning: File not found during hashing: {filename}")
34
- return None # Or another default value
35
- return hash_md5.hexdigest()
36
-
37
- def find_files(folder, exclude=None):
38
- """Recursively finds all files in a directory and calculates their hashes.
39
-
40
- Ignores files/directories whose path contains the `exclude` string.
41
- Normalizes path separators to '/' for consistency across OS.
42
-
43
- Args:
44
- folder (str): The root directory to search.
45
- exclude (str, optional): A string (or regex pattern), paths containing it will be excluded.
46
- Defaults to None (nothing excluded).
47
-
48
- Yields:
49
- tuple: A tuple (relative_path, md5_hash) for each found file.
50
- The relative path uses '/' as a separator.
51
- """
52
- if not folder.endswith(os.path.sep):
53
- folder += os.path.sep
54
-
55
- for root, dirs, files in os.walk(folder):
56
- # Optionally: Exclude directories at the os.walk level for efficiency
57
- if exclude:
58
- dirs[:] = [d for d in dirs if exclude not in os.path.join(root, d)]
59
-
60
- for file in files:
61
- filename = os.path.join(root, file)
62
- relative_path = filename.replace(folder, '').replace("\\", '/') # Path normalization
63
-
64
- # Check for exclusion
65
- if exclude and exclude in filename: # Check the full path for exclude
66
- continue # Skip the file if it matches the exclusion pattern
67
-
68
- file_hash = get_hash(filename)
69
- if file_hash: # Ensure the hash was obtained (file wasn't deleted)
70
- yield relative_path, file_hash
71
-
72
-
73
- def GetState(folder, exclude=None):
74
- """Creates a dictionary representing the state of a directory (file -> hash).
75
-
76
- Uses find_files to get the list of files and their hashes.
77
-
78
- Args:
79
- folder (str): Path to the directory.
80
- exclude (str, optional): Pattern to exclude files/directories.
81
-
82
- Returns:
83
- dict: A dictionary where keys are relative file paths (with '/' separator),
84
- and values are their MD5 hashes.
85
- """
86
- return dict(find_files(folder, exclude))
87
-
88
-
89
- def GetStateHash(state):
90
- """Calculates a single MD5 hash for the entire directory state.
91
-
92
- The hash depends on file names and their hashes. Sorting keys ensures
93
- the same hash for the same state, regardless of file traversal order.
94
-
95
- Args:
96
- state (dict): The directory state dictionary (result of GetState).
97
-
98
- Returns:
99
- str: The overall MD5 hash of the state as a hexadecimal string.
100
- """
101
- hashid = hashlib.md5()
102
- # Sort items by key (file path) for hash consistency
103
- ordered = OrderedDict(sorted(state.items()))
104
- for k, v in ordered.items():
105
- hashid.update(k.encode('utf-8')) # Encode the key (path)
106
- hashid.update(v.encode('utf-8')) # Encode the value (file hash)
107
- return hashid.hexdigest()
108
-
109
-
110
- def GetDiff(state1, state2):
111
- """Calculates the difference between two directory states.
112
-
113
- Determines added, removed, and changed files.
114
-
115
- Args:
116
- state1 (dict): The initial state (file -> hash dictionary).
117
- state2 (dict): The final state (file -> hash dictionary).
118
-
119
- Returns:
120
- dict: A dictionary with difference information:
121
- - 'removed': list[str] - list of removed files.
122
- - 'added': list[str] - list of added files.
123
- - 'changed': list[str] - list of changed files (different hashes).
124
- - 'state': dict - the final state (state2).
125
- - 'md5': dict - hash dictionary for added and changed files from state2.
126
- - 'source_state': str - hash of the initial state (state1).
127
- - 'target_state': str - hash of the final state (state2).
128
- """
129
- keysA = set(state1.keys())
130
- keysB = set(state2.keys())
131
-
132
- removed = keysA - keysB # Files present in state1 but not in state2
133
- added = keysB - keysA # Files present in state2 but not in state1
134
- keep = keysA.intersection(keysB) # Files present in both states
135
-
136
- # Changed files are those present in both states but with different hashes
137
- changed = [key for key in keep if state1[key] != state2[key]]
138
-
139
- result = {
140
- 'removed': list(removed),
141
- 'added': list(added),
142
- 'changed': list(changed),
143
- 'state': state2, # Store the final state in the diff
144
- 'md5': {}, # Hashes of files to be included in the patch
145
- 'source_state': GetStateHash(state1), # Hash of the source state
146
- 'target_state': GetStateHash(state2) # Hash of the target state
147
- }
148
-
149
- # Collect hashes for all files that were added or changed
150
- allfiles_to_include = set(changed)
151
- allfiles_to_include.update(added)
152
- for key in allfiles_to_include:
153
- result['md5'][key] = state2[key] # Take the hash from the final state
154
-
155
- return result
156
-
157
-
158
- def CreatePatch(source_folder, patch_file, diff):
159
- """Creates a ZIP archive (patch) containing the changes.
160
-
161
- The patch includes metadata (diff information) and the necessary files
162
- (added and changed).
163
-
164
- Args:
165
- source_folder (str): The folder from which changed and added files are taken.
166
- patch_file (str): The filename for the created ZIP patch.
167
- diff (dict): The difference dictionary obtained from GetDiff.
168
- """
169
- with ZipFile(patch_file, "w") as z:
170
- # Write metadata to metadata.json inside the archive
171
- z.writestr("metadata.json", data=json.dumps(diff, indent=4, ensure_ascii=False))
172
-
173
- # Add all added files to the archive
174
- for file in diff.get('added', []):
175
- source_path = os.path.join(source_folder, ClearPatch(file)) # Path to the file in the source folder
176
- # arcname=file ensures the relative path is preserved inside the archive
177
- z.write(source_path, arcname=file)
178
-
179
- # Add all changed files to the archive
180
- for file in diff.get('changed', []):
181
- source_path = os.path.join(source_folder, ClearPatch(file)) # Path to the file in the source folder
182
- z.write(source_path, arcname=file)
183
-
184
-
185
- def ClearPatch(path):
186
- """Normalizes path separators to be specific to the current OS.
187
-
188
- Converts '/' to '\' on Windows and vice versa on other systems.
189
- Used to construct correct file paths when reading/writing
190
- in the file system, as paths within the diff and patch are stored with '/'.
191
-
192
- Args:
193
- path (str): Path with '/' separators.
194
-
195
- Returns:
196
- str: Path with separators specific to the current OS.
197
- """
198
- if sep == "\\": # If OS is Windows
199
- return path.replace("/", "\\")
200
- else: # For Linux, macOS, and other POSIX-compliant systems
201
- return path.replace("\\", "/") # Replace backslashes just in case
202
-
203
-
204
- def CheckGitRepo(target):
205
- """Checks the integrity of a Git repository using 'git fsck'.
206
-
207
- This function seems specific and possibly unrelated to the core
208
- patching logic. Is it used anywhere?
209
-
210
- Args:
211
- target (str): Path to the directory assumed to be a Git repository.
212
-
213
- Returns:
214
- bool: True if 'git fsck' completed successfully (return code 0).
215
-
216
- Raises:
217
- Exception: If 'git fsck' failed or if the git command is not found.
218
- """
219
- try:
220
- # Run 'git fsck' in the specified directory
221
- process = Popen(["git", "fsck"], cwd=target, stdout=PIPE, stderr=PIPE, text=True, encoding='utf-8')
222
- stdout, stderr = process.communicate()
223
- code = process.wait()
224
- if code != 0:
225
- # If the command failed, raise an exception
226
- raise Exception(f"Git fsck failed in {target}:\nSTDOUT: {stdout}\nSTDERR: {stderr}")
227
- print(f"Git fsck successful in {target}")
228
- return True
229
- except FileNotFoundError:
230
- raise Exception(f"Git command not found. Is Git installed and in PATH?")
231
- except Exception as e:
232
- # Catch other potential Popen errors
233
- raise Exception(f"Error running git fsck in {target}: {e}")
234
-
235
-
236
- def ApplyPatch(target, patch_file, exclude=None):
237
- """Applies a patch to the target directory.
238
-
239
- Verifies that the current state of the target directory matches
240
- the source state specified in the patch. Deletes, adds, and updates files
241
- according to the patch metadata.
242
-
243
- Args:
244
- target (str): Path to the target directory where the patch is applied.
245
- patch_file (str): Path to the ZIP patch file.
246
- exclude (str, optional): Pattern to exclude files when checking the
247
- current state of the target directory.
248
-
249
- Returns:
250
- bool: True if the patch was successfully applied or if the directory
251
- was already in the target state.
252
-
253
- Raises:
254
- FileNotFoundError: If the target directory or patch file does not exist.
255
- ValueError: If the patch file is invalid (missing or corrupt metadata.json).
256
- Exception: If the current state of the target directory does not match
257
- the source state required by the patch.
258
- AssertionError: If the hash of an extracted file does not match the hash
259
- specified in the patch metadata (integrity check failure).
260
- """
261
- if not os.path.isdir(target):
262
- raise FileNotFoundError(f"Target directory not found: {target}")
263
- if not os.path.isfile(patch_file):
264
- raise FileNotFoundError(f"Patch file not found: {patch_file}")
265
-
266
- with ZipFile(patch_file, "r") as patch:
267
- # Read metadata from the patch
268
- try:
269
- with patch.open('metadata.json', 'r') as metadata_file:
270
- # Read as bytes and decode as utf-8 (more robust)
271
- diff = json.loads(metadata_file.read().decode('utf-8'))
272
- except KeyError:
273
- raise ValueError("Invalid patch file: metadata.json not found.")
274
- except json.JSONDecodeError:
275
- raise ValueError("Invalid patch file: metadata.json is corrupted.")
276
-
277
- print(f"Patch contains: Removed: {len(diff.get('removed',[]))}, Added: {len(diff.get('added',[]))}, Changed: {len(diff.get('changed',[]))}")
278
-
279
- # Get the current state of the target directory
280
- current_state = GetState(target, exclude)
281
- state_hash = GetStateHash(current_state)
282
-
283
- print(f"Current state hash: {state_hash}")
284
- print(f"Patch source state hash: {diff.get('source_state')}")
285
- print(f"Patch target state hash: {diff.get('target_state')}")
286
-
287
- # Check if the patch needs to be applied at all
288
- if diff.get('target_state') == state_hash:
289
- print("Target directory is already in the target state. No action needed.")
290
- return True
291
-
292
- # Check if the current state matches the patch's source state
293
- if diff.get('source_state') != state_hash:
294
- print("Current state:", json.dumps(current_state, indent=2))
295
- # print("Expected source state hash from patch:", diff.get('source_state'))
296
- raise Exception("The current state of the target directory does not match the source state required by the patch.")
297
-
298
- # --- Applying changes ---
299
- print("Applying patch...")
300
-
301
- # 1. Deleting files
302
- for filename in diff.get('removed', []):
303
- path_to_remove = ClearPatch(os.path.join(target, filename))
304
- if os.path.isfile(path_to_remove):
305
- try:
306
- os.remove(path_to_remove)
307
- print(f"- Removed: {path_to_remove}")
308
- except OSError as e:
309
- print(f"Warning: Could not remove file {path_to_remove}: {e}")
310
- else:
311
- print(f"Warning: File to remove not found (already removed?): {path_to_remove}")
312
-
313
- # 2. Extracting/Updating files (added and changed)
314
- files_to_extract = diff.get('added', []) + diff.get('changed', [])
315
- patch_md5_map = diff.get('md5', {})
316
-
317
- for filename in files_to_extract:
318
- target_path = ClearPatch(os.path.join(target, filename))
319
- target_file_dir = os.path.dirname(target_path)
320
-
321
- # Create parent directories if they don't exist
322
- Path(target_file_dir).mkdir(parents=True, exist_ok=True)
323
-
324
- # Remove the old file if it exists (for changed files)
325
- if filename in diff.get('changed', []) and os.path.exists(target_path):
326
- try:
327
- # Add a check to ensure it's a file, not a directory
328
- if os.path.isfile(target_path):
329
- os.remove(target_path)
330
- else:
331
- print(f"Warning: Expected file but found directory at {target_path}. Skipping removal.")
332
- continue # Skip this file
333
- except OSError as e:
334
- print(f"Warning: Could not remove existing file {target_path} before update: {e}")
335
- continue # Skip this file
336
-
337
- try:
338
- # Extract the file from the archive
339
- patch.extract(filename, target)
340
- action = "+" if filename in diff.get('added', []) else "*"
341
- print(f"{action} Extracted: {target_path}")
342
-
343
- # Verify the hash of the extracted file
344
- extracted_hash = get_hash(target_path)
345
- expected_hash = patch_md5_map.get(filename)
346
-
347
- if not expected_hash:
348
- print(f"Warning: No expected hash found in patch metadata for {filename}. Skipping check.")
349
- elif not extracted_hash:
350
- print(f"Warning: Could not calculate hash for extracted file {target_path} (likely deleted during process?). Skipping check.")
351
- elif extracted_hash != expected_hash:
352
- # If hashes don't match, it's a serious problem
353
- raise AssertionError(
354
- f"Hash mismatch for extracted file {target_path}! "
355
- f"Expected {expected_hash}, got {extracted_hash}. Patch or extraction failed."
356
- )
357
-
358
- except KeyError:
359
- print(f"Warning: File '{filename}' listed in patch metadata but not found in the archive.")
360
- except Exception as e:
361
- print(f"Error extracting or verifying file {filename}: {e}")
362
- # Decide whether to stop the whole process or just skip the file
363
- # raise e # Uncomment to stop patch application on error
364
-
365
- print("Patch applied successfully.")
366
- # Optional final check: hash of the state after patching should match target_state
367
- final_state_hash = GetStateHash(GetState(target, exclude))
368
- if final_state_hash != diff.get('target_state'):
369
- print(f"Warning: Final state hash ({final_state_hash}) does not match patch target state hash ({diff.get('target_state')}). This might indicate issues during patching or with excluded files.")
370
-
371
- return True
File without changes