py-zippy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zippy/repair.py ADDED
@@ -0,0 +1,243 @@
1
+ import bz2
2
+ import gzip
3
+ import lzma
4
+ import os
5
+ import shutil
6
+ import tarfile
7
+ import zipfile
8
+
9
+ try:
10
+ import pyzipper
11
+ except ImportError: # pragma: no cover - optional dependency
12
+ pyzipper = None
13
+
14
+ from .utils import (
15
+ Fore,
16
+ _salvage_extract_on_repair_fail,
17
+ _tar_salvage_extraction,
18
+ color_text,
19
+ get_archive_type,
20
+ get_logger,
21
+ handle_errors,
22
+ loading_animation,
23
+ )
24
+
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ def _open_zip_reader(path, password, verbose):
30
+ if password:
31
+ pwd_bytes = password.encode("utf-8")
32
+ if pyzipper:
33
+ zf = pyzipper.AESZipFile(path, "r")
34
+ zf.pwd = pwd_bytes
35
+ return zf
36
+ reader = zipfile.ZipFile(path, "r")
37
+ reader.setpassword(pwd_bytes)
38
+ return reader
39
+ return zipfile.ZipFile(path, "r")
40
+
41
+
42
+ def _open_zip_writer(path, password):
43
+ if password and pyzipper:
44
+ writer = pyzipper.AESZipFile(
45
+ path,
46
+ "w",
47
+ compression=zipfile.ZIP_DEFLATED,
48
+ encryption=pyzipper.WZ_AES,
49
+ )
50
+ writer.pwd = password.encode("utf-8")
51
+ writer.setencryption(pyzipper.WZ_AES, nbits=256)
52
+ return writer
53
+ return zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED)
54
+
55
+
56
+ def repair_archive(
57
+ archive_path,
58
+ verbose=False,
59
+ disable_animation=False,
60
+ repair_mode="remove_corrupted",
61
+ password=None,
62
+ ):
63
+ """
64
+ Repairs a corrupted archive.
65
+
66
+ Parameters:
67
+ - archive_path (str): Path to the archive file.
68
+ - verbose (bool): Enable verbose output for debugging.
69
+ - disable_animation (bool): Disable loading animation.
70
+ - repair_mode (str): Repair mode for ZIP archives (default: remove_corrupted).
71
+
72
+ Raises:
73
+ - ValueError: If the archive type is unsupported.
74
+ - RuntimeError: If repair fails due to various issues.
75
+ """
76
+ archive_type = get_archive_type(archive_path)
77
+ supported = {
78
+ "zip",
79
+ "tar",
80
+ "tar.gz",
81
+ "tar.bz2",
82
+ "tar.xz",
83
+ "tar.lzma",
84
+ "gzip",
85
+ "bz2",
86
+ "xz",
87
+ "lzma",
88
+ }
89
+ if archive_type not in supported:
90
+ handle_errors(
91
+ "Repair operation is only supported for ZIP, TAR (gz/bz2/xz/lzma) and single-file gzip/bz2/xz/lzma archives at this time."
92
+ )
93
+ logger.warning(
94
+ color_text(
95
+ "[Experimental Feature] Archive repair is a complex process and may not always be successful.",
96
+ Fore.YELLOW if Fore else None,
97
+ )
98
+ )
99
+ logger.info("Repair mode: %s", repair_mode)
100
+ repair_attempted = False
101
+ try:
102
+ loading_animation(
103
+ f"Attempting to repair {os.path.basename(archive_path)}",
104
+ duration=3,
105
+ disable_animation=disable_animation,
106
+ )
107
+ if archive_type == "zip":
108
+ try:
109
+ with _open_zip_reader(archive_path, password, verbose) as zf:
110
+ bad_file = zf.testzip()
111
+ if bad_file:
112
+ logger.warning("Possible corruption detected in: %s", bad_file)
113
+ if repair_mode == "remove_corrupted":
114
+ repair_attempted = True
115
+ logger.info(
116
+ "Attempting to repair by removing corrupted file: %s",
117
+ bad_file,
118
+ )
119
+ temp_zip_path = archive_path + ".temp_repair.zip"
120
+ with _open_zip_writer(temp_zip_path, password) as temp_zf:
121
+ with _open_zip_reader(
122
+ archive_path, password, verbose
123
+ ) as original_zf:
124
+ for item in original_zf.infolist():
125
+ if item.filename != bad_file:
126
+ try:
127
+ data = original_zf.read(item.filename)
128
+ temp_zf.writestr(item, data)
129
+ except Exception as e_read:
130
+ logger.warning(
131
+ "Could not copy %s. Error: %s",
132
+ item.filename,
133
+ e_read,
134
+ )
135
+ os.remove(archive_path)
136
+ os.rename(temp_zip_path, archive_path)
137
+ logger.info(
138
+ "Repair finished. Corrupted file '%s' removed. Repaired archive: %s",
139
+ bad_file,
140
+ archive_path,
141
+ )
142
+ elif repair_mode == "scan_only":
143
+ logger.info(
144
+ "Scan-only mode: corruption reported; no changes made."
145
+ )
146
+ else:
147
+ logger.warning(
148
+ "Unknown repair mode '%s'. No repair action taken.",
149
+ repair_mode,
150
+ )
151
+ else:
152
+ logger.info(
153
+ "Integrity check passed for %s. No major errors detected.",
154
+ archive_path,
155
+ )
156
+ except zipfile.BadZipFile as e:
157
+ logger.error("ZIP archive appears to be badly corrupted: %s", e)
158
+ logger.error("Specialised ZIP repair tools may be required.")
159
+ except RuntimeError as e:
160
+ if "password" in str(e).lower() or "encrypted" in str(e).lower():
161
+ handle_errors(
162
+ "Password is required to repair encrypted ZIP archives.",
163
+ verbose,
164
+ )
165
+ else:
166
+ handle_errors(f"Error during ZIP repair attempt: {e}", verbose)
167
+ except Exception as e:
168
+ handle_errors(f"Error during ZIP repair attempt: {e}", verbose)
169
+ elif archive_type.startswith("tar"):
170
+ repair_attempted = True
171
+ logger.info("Enhanced TAR repair: Attempting to extract readable files...")
172
+ extracted_files_dir = (
173
+ f"{os.path.basename(archive_path)}_extracted_during_repair"
174
+ )
175
+ os.makedirs(extracted_files_dir, exist_ok=True)
176
+ extracted_count = _tar_salvage_extraction(
177
+ archive_path, extracted_files_dir, verbose
178
+ )
179
+ if extracted_count > 0:
180
+ logger.info(
181
+ "Extracted %s files from TAR archive to: %s",
182
+ extracted_count,
183
+ extracted_files_dir,
184
+ )
185
+ logger.warning(
186
+ "Salvage operation only. Original archive may remain corrupted."
187
+ )
188
+ else:
189
+ logger.warning(
190
+ "No files could be extracted from the TAR archive. It may be severely corrupted."
191
+ )
192
+ elif archive_type in {"gzip", "bz2", "xz", "lzma"}:
193
+ repair_attempted = True
194
+ logger.info(
195
+ "%s repair: Attempting basic decompression to salvage content...",
196
+ archive_type.upper(),
197
+ )
198
+ output_file_name = (
199
+ f"{os.path.splitext(os.path.basename(archive_path))[0]}_recovered"
200
+ )
201
+ try:
202
+ if archive_type == "gzip":
203
+ opener = gzip.open
204
+ kwargs = {}
205
+ elif archive_type == "bz2":
206
+ opener = bz2.open
207
+ kwargs = {}
208
+ elif archive_type == "xz":
209
+ opener = lzma.open
210
+ kwargs = {"format": lzma.FORMAT_XZ}
211
+ else: # lzma
212
+ opener = lzma.open
213
+ kwargs = {"format": lzma.FORMAT_ALONE}
214
+ with opener(archive_path, "rb", **kwargs) as comp:
215
+ with open(output_file_name, "wb") as outfile:
216
+ shutil.copyfileobj(comp, outfile)
217
+ logger.info(
218
+ "Successfully decompressed content to: %s", output_file_name
219
+ )
220
+ except (gzip.BadGzipFile, OSError, lzma.LZMAError) as e:
221
+ logger.error(
222
+ "%s archive appears to be corrupted: %s", archive_type.upper(), e
223
+ )
224
+ logger.error("Specialised tools may be required for deeper recovery.")
225
+ except Exception as e:
226
+ handle_errors(
227
+ f"Error during {archive_type.upper()} repair attempt: {e}", verbose
228
+ )
229
+ logger.info("[Repair attempt finished. Results may vary.]")
230
+ if not repair_attempted and archive_type not in {"gzip", "bz2", "xz", "lzma"}:
231
+ logger.info(
232
+ "No repair action taken based on the integrity check (or in scan_only mode)."
233
+ )
234
+ if repair_attempted or archive_type in {"gzip", "bz2", "xz", "lzma"}:
235
+ salvage_output_dir_name = (
236
+ f"{os.path.basename(archive_path)}_salvaged_content"
237
+ )
238
+ _salvage_extract_on_repair_fail(
239
+ archive_path, salvage_output_dir_name, archive_type, verbose
240
+ )
241
+ logger.warning("It's recommended to have backups of important archives.")
242
+ except Exception as e:
243
+ handle_errors(f"Repair operation failed: {e}", verbose)
zippy/test.py ADDED
@@ -0,0 +1,153 @@
1
+ import bz2
2
+ import gzip
3
+ import lzma
4
+ import os
5
+ import tarfile
6
+ import zipfile
7
+
8
+ try:
9
+ import pyzipper
10
+ except ImportError: # pragma: no cover - optional dependency
11
+ pyzipper = None
12
+
13
+ from .utils import (
14
+ get_logger,
15
+ get_archive_type,
16
+ handle_errors,
17
+ is_single_file_type,
18
+ loading_animation,
19
+ requires_external_tool,
20
+ external_test,
21
+ tar_read_mode,
22
+ )
23
+
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ def _test_zip_with_pyzipper(archive_path, password, verbose):
29
+ if not pyzipper:
30
+ handle_errors(
31
+ "Encrypted ZIP integrity checking requires the 'pyzipper' package.",
32
+ verbose,
33
+ )
34
+ if not password:
35
+ handle_errors("Password is required to test encrypted ZIP archives.", verbose)
36
+ with pyzipper.AESZipFile(archive_path, "r") as zf:
37
+ zf.pwd = password.encode("utf-8")
38
+ for info in zf.infolist():
39
+ if info.is_dir():
40
+ continue
41
+ zf.read(info.filename)
42
+ logger.info("Integrity test for %s: [OK] (AES ZIP)", archive_path)
43
+
44
+
45
+ def test_archive_integrity(
46
+ archive_path, verbose=False, disable_animation=False, password=None
47
+ ):
48
+ """
49
+ Tests the integrity of an archive.
50
+
51
+ Parameters:
52
+ - archive_path (str): Path to the archive file.
53
+ - verbose (bool): Enable verbose output for debugging.
54
+ - disable_animation (bool): Disable loading animation.
55
+
56
+ Raises:
57
+ - ValueError: If the archive type is unsupported.
58
+ - RuntimeError: If integrity test fails due to various issues.
59
+ """
60
+ archive_type = get_archive_type(archive_path)
61
+ if not archive_type:
62
+ handle_errors(f"Unsupported archive type for: {archive_path}")
63
+ try:
64
+ loading_animation(
65
+ f"Testing integrity of {os.path.basename(archive_path)}",
66
+ duration=1,
67
+ disable_animation=disable_animation,
68
+ )
69
+ if archive_type == "zip":
70
+ try:
71
+ with zipfile.ZipFile(archive_path, "r") as zf:
72
+ if password:
73
+ zf.setpassword(password.encode("utf-8"))
74
+ result = zf.testzip()
75
+ if result is None:
76
+ logger.info("Integrity test for %s: [OK]", archive_path)
77
+ else:
78
+ handle_errors(
79
+ f"Integrity test failed for {archive_path}. Corrupted file: {result}",
80
+ exit_code=2,
81
+ )
82
+ except RuntimeError as e:
83
+ lower = str(e).lower()
84
+ if (
85
+ "password" in lower
86
+ or "encrypted" in lower
87
+ or "compression method" in lower
88
+ ):
89
+ _test_zip_with_pyzipper(archive_path, password, verbose)
90
+ else:
91
+ handle_errors(
92
+ f"Integrity test failed for {archive_path}. {e}", exit_code=2
93
+ )
94
+ except NotImplementedError:
95
+ _test_zip_with_pyzipper(archive_path, password, verbose)
96
+ elif archive_type.startswith("tar"):
97
+ try:
98
+ with tarfile.open(archive_path, tar_read_mode(archive_type)) as tf:
99
+ tf.getnames()
100
+ logger.info(
101
+ "Integrity test for %s: [OK] (Basic TAR check)", archive_path
102
+ )
103
+ except tarfile.ReadError as e:
104
+ handle_errors(
105
+ f"Integrity test failed for {archive_path}. Possible corruption: {e}",
106
+ exit_code=2,
107
+ )
108
+ elif is_single_file_type(archive_type):
109
+ try:
110
+ if archive_type == "gzip":
111
+ opener = gzip.open
112
+ kwargs = {}
113
+ error_type = gzip.BadGzipFile
114
+ elif archive_type == "bz2":
115
+ opener = bz2.open
116
+ kwargs = {}
117
+ error_type = OSError
118
+ elif archive_type == "xz":
119
+ opener = lzma.open
120
+ kwargs = {"format": lzma.FORMAT_XZ}
121
+ error_type = lzma.LZMAError
122
+ elif archive_type == "lzma":
123
+ opener = lzma.open
124
+ kwargs = {"format": lzma.FORMAT_ALONE}
125
+ error_type = lzma.LZMAError
126
+ else:
127
+ handle_errors(
128
+ f"Integrity test for {archive_type} not implemented.", verbose
129
+ )
130
+ return
131
+ with opener(archive_path, "rb", **kwargs) as stream:
132
+ stream.read(1024)
133
+ logger.info(
134
+ "Integrity test for %s: [OK] (Basic %s check)",
135
+ archive_path,
136
+ archive_type.upper(),
137
+ )
138
+ except error_type as e: # type: ignore[name-defined]
139
+ handle_errors(
140
+ f"Integrity test failed for {archive_path}. Possible corruption: {e}",
141
+ exit_code=2,
142
+ )
143
+ elif requires_external_tool(archive_type):
144
+ external_test(archive_path, verbose=verbose)
145
+ logger.info(
146
+ "Integrity test for %s: [OK] (External backend)", archive_path
147
+ )
148
+ else:
149
+ handle_errors(
150
+ f"Integrity test for {archive_type} not implemented.", verbose
151
+ )
152
+ except Exception as e:
153
+ handle_errors(f"Integrity test could not be performed: {e}", verbose)
zippy/unlock.py ADDED
@@ -0,0 +1,120 @@
1
+ import os
2
+ import zipfile
3
+
4
+ try:
5
+ import pyzipper
6
+ except ImportError: # pragma: no cover - optional but recommended dependency
7
+ pyzipper = None
8
+
9
+ from .utils import (
10
+ Fore,
11
+ color_text,
12
+ get_logger,
13
+ get_archive_type,
14
+ handle_errors,
15
+ loading_animation,
16
+ validate_path,
17
+ )
18
+
19
+ PASSWORD_DICT_DEFAULT = "password_list.txt"
20
+
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def unlock_archive(
26
+ archive_path,
27
+ dictionary_file=PASSWORD_DICT_DEFAULT,
28
+ password=None,
29
+ verbose=False,
30
+ disable_animation=False,
31
+ ):
32
+ """
33
+ Attempts to unlock a password-protected ZIP archive using a provided password or a dictionary attack.
34
+
35
+ Parameters:
36
+ - archive_path (str): Path to the archive file.
37
+ - dictionary_file (str): Path to the dictionary file containing possible passwords.
38
+ - password (str): Password for the archive (if known).
39
+ - verbose (bool): Enable verbose output for debugging.
40
+ - disable_animation (bool): Disable loading animation.
41
+
42
+ Raises:
43
+ - ValueError: If the archive type is unsupported or no passwords are provided.
44
+ - RuntimeError: If unlocking fails due to incorrect password or other issues.
45
+ """
46
+ archive_type = get_archive_type(archive_path)
47
+ if archive_type != "zip":
48
+ handle_errors(
49
+ "Unlock operation is only supported for ZIP archives at this time."
50
+ )
51
+ if not password and not dictionary_file:
52
+ handle_errors("Please provide a password or a dictionary file for unlocking.")
53
+ if dictionary_file:
54
+ dictionary_path = validate_path(
55
+ dictionary_file, "Dictionary file", must_exist=True, is_dir=False
56
+ )
57
+ if password:
58
+ passwords_to_try = [password]
59
+ elif dictionary_file:
60
+ try:
61
+ with open(dictionary_path, "r", encoding="utf-8", errors="ignore") as df:
62
+ passwords_to_try = [
63
+ line.strip()
64
+ for line in df
65
+ if line.strip() and not line.startswith("#")
66
+ ]
67
+ except FileNotFoundError:
68
+ handle_errors(f"Dictionary file not found: {dictionary_path}")
69
+ except Exception as e:
70
+ handle_errors(f"Error reading dictionary file: {e}", verbose)
71
+ else:
72
+ handle_errors("No passwords to try for unlocking.")
73
+ try:
74
+ loading_animation(
75
+ f"Attempting to unlock {os.path.basename(archive_path)}",
76
+ duration=2,
77
+ disable_animation=disable_animation,
78
+ )
79
+ found_password = False
80
+ for pwd in passwords_to_try:
81
+ try_password = pwd.encode("utf-8", errors="ignore")
82
+ try:
83
+ if pyzipper:
84
+ with pyzipper.AESZipFile(archive_path, "r") as zf:
85
+ zf.pwd = try_password
86
+ zf.extractall()
87
+ else:
88
+ with zipfile.ZipFile(archive_path, "r") as zf:
89
+ zf.extractall(pwd=try_password)
90
+ logger.info(
91
+ color_text(f"Password found: {pwd}", Fore.GREEN if Fore else None)
92
+ )
93
+ found_password = True
94
+ break
95
+ except RuntimeError as e:
96
+ if (
97
+ "bad password" in str(e).lower()
98
+ or "incorrect password" in str(e).lower()
99
+ ):
100
+ if verbose:
101
+ logger.debug("Trying password '%s' - failed", pwd)
102
+ continue
103
+ if "requires AES" in str(e).lower() and not pyzipper:
104
+ handle_errors(
105
+ "Archive appears to use AES encryption. Install the 'pyzipper' package to unlock it.",
106
+ verbose,
107
+ )
108
+ handle_errors(f"Unlock attempt failed due to: {e}", verbose)
109
+ break
110
+ except (zipfile.BadZipFile, OSError) as e:
111
+ handle_errors(f"Unlock attempt failed unexpectedly: {e}", verbose)
112
+ break
113
+ if not found_password:
114
+ logger.warning("Password not found in the provided list.")
115
+ if dictionary_file:
116
+ logger.info("Tried passwords from dictionary: %s", dictionary_file)
117
+ if password:
118
+ logger.info("Explicit password tested: %s", password)
119
+ except Exception as e:
120
+ handle_errors(f"Password unlocking process failed: {e}", verbose)