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.
- {stateman-1.3.1 → stateman-2.0.0}/PKG-INFO +31 -3
- {stateman-1.3.1 → stateman-2.0.0}/README.md +28 -1
- {stateman-1.3.1 → stateman-2.0.0}/pyproject.toml +5 -7
- stateman-2.0.0/stateman/__init__.py +507 -0
- stateman-1.3.1/stateman/__init__.py +0 -371
- {stateman-1.3.1 → stateman-2.0.0}/stateman/__main__.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: stateman
|
|
3
|
-
Version:
|
|
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
|
|
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
|

|
|
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
|
|
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
|

|
|
35
62
|
|
|
36
63
|
## Usage Example:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stateman"
|
|
3
|
-
version = "
|
|
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
|