zipctl 0.1.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.
- zipctl/__init__.py +108 -0
- zipctl/__main__.py +5 -0
- zipctl/cli/__init__.py +108 -0
- zipctl/cli/archive.py +23 -0
- zipctl/cli/atomic.py +77 -0
- zipctl/cli/commands/__init__.py +8 -0
- zipctl/cli/commands/check_password.py +133 -0
- zipctl/cli/commands/create.py +374 -0
- zipctl/cli/commands/decrypt.py +70 -0
- zipctl/cli/commands/encrypt.py +102 -0
- zipctl/cli/commands/extract.py +262 -0
- zipctl/cli/commands/helpers/__init__.py +1 -0
- zipctl/cli/commands/helpers/collect.py +225 -0
- zipctl/cli/commands/helpers/command.py +119 -0
- zipctl/cli/commands/helpers/copying/__init__.py +1 -0
- zipctl/cli/commands/helpers/copying/copy.py +211 -0
- zipctl/cli/commands/helpers/copying/options.py +80 -0
- zipctl/cli/commands/helpers/copying/report.py +63 -0
- zipctl/cli/commands/helpers/copying/targets.py +76 -0
- zipctl/cli/commands/helpers/copying/verify.py +111 -0
- zipctl/cli/commands/helpers/create_report.py +154 -0
- zipctl/cli/commands/helpers/encryption/__init__.py +1 -0
- zipctl/cli/commands/helpers/encryption/options.py +192 -0
- zipctl/cli/commands/helpers/encryption/plan.py +119 -0
- zipctl/cli/commands/helpers/encryption/spec.py +256 -0
- zipctl/cli/commands/helpers/extract_report.py +148 -0
- zipctl/cli/commands/helpers/output_options.py +55 -0
- zipctl/cli/commands/helpers/passwords/__init__.py +1 -0
- zipctl/cli/commands/helpers/passwords/options.py +143 -0
- zipctl/cli/commands/helpers/passwords/pool.py +116 -0
- zipctl/cli/commands/helpers/passwords/sources.py +144 -0
- zipctl/cli/commands/helpers/policy_options.py +64 -0
- zipctl/cli/commands/helpers/progress.py +215 -0
- zipctl/cli/commands/helpers/selection.py +96 -0
- zipctl/cli/commands/helpers/sources.py +32 -0
- zipctl/cli/commands/inspect.py +145 -0
- zipctl/cli/commands/list.py +166 -0
- zipctl/cli/commands/policy.py +104 -0
- zipctl/cli/commands/rewrite.py +113 -0
- zipctl/cli/commands/test.py +154 -0
- zipctl/cli/context.py +57 -0
- zipctl/cli/errors.py +58 -0
- zipctl/cli/formatter.py +62 -0
- zipctl/cli/methods.py +136 -0
- zipctl/cli/output.py +138 -0
- zipctl/cli/parser.py +197 -0
- zipctl/compression/__init__.py +129 -0
- zipctl/compression/bz2.py +99 -0
- zipctl/compression/deflate.py +136 -0
- zipctl/compression/lzma.py +238 -0
- zipctl/compression/methods.py +176 -0
- zipctl/compression/stored.py +14 -0
- zipctl/compression/zstd.py +141 -0
- zipctl/cryptography/__init__.py +41 -0
- zipctl/cryptography/aes.py +500 -0
- zipctl/cryptography/base.py +163 -0
- zipctl/cryptography/zipcrypto.py +251 -0
- zipctl/exceptions.py +39 -0
- zipctl/py.typed +0 -0
- zipctl/zipfile/__init__.py +1 -0
- zipctl/zipfile/assessment.py +41 -0
- zipctl/zipfile/assessor.py +151 -0
- zipctl/zipfile/exceptions.py +27 -0
- zipctl/zipfile/ext.py +782 -0
- zipctl/zipfile/extract.py +186 -0
- zipctl/zipfile/file.py +1500 -0
- zipctl/zipfile/info.py +1127 -0
- zipctl/zipfile/inspection.py +127 -0
- zipctl/zipfile/io_wrappers.py +193 -0
- zipctl/zipfile/materialize.py +344 -0
- zipctl/zipfile/password.py +87 -0
- zipctl/zipfile/path/__init__.py +523 -0
- zipctl/zipfile/path/glob.py +104 -0
- zipctl/zipfile/policy_config.py +426 -0
- zipctl/zipfile/policy_extraction.py +267 -0
- zipctl/zipfile/progress.py +133 -0
- zipctl/zipfile/records.py +504 -0
- zipctl/zipfile/secure_fs.py +128 -0
- zipctl/zipfile/shared.py +104 -0
- zipctl/zipfile/validators.py +392 -0
- zipctl/zipfile/write.py +311 -0
- zipctl/zipfile/write_coordinator.py +111 -0
- zipctl-0.1.0.dist-info/METADATA +730 -0
- zipctl-0.1.0.dist-info/RECORD +91 -0
- zipctl-0.1.0.dist-info/WHEEL +4 -0
- zipctl-0.1.0.dist-info/entry_points.txt +2 -0
- zipctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- zipctl-0.1.0.dist-info/licenses/NOTICE +32 -0
- zipctl-0.1.0.dist-info/licenses/licenses/CPYTHON-3.14.3.txt +277 -0
- zipctl-0.1.0.dist-info/licenses/licenses/JetBrainsMono-OFL.txt +93 -0
- zipctl-0.1.0.dist-info/licenses/licenses/pyzipper-MIT.txt +22 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Reading the input archive and writing every member into the new one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zlib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from zipctl.cli.archive import CHUNK
|
|
9
|
+
from zipctl.cli.atomic import replacing
|
|
10
|
+
from zipctl.cli.commands.helpers.copying.options import CopyJob
|
|
11
|
+
from zipctl.cli.commands.helpers.copying.report import report_copy
|
|
12
|
+
from zipctl.cli.commands.helpers.copying.targets import (
|
|
13
|
+
Copied,
|
|
14
|
+
Target,
|
|
15
|
+
describe_failure,
|
|
16
|
+
)
|
|
17
|
+
from zipctl.cli.commands.helpers.copying.verify import verify_copy
|
|
18
|
+
from zipctl.cli.commands.helpers.passwords.pool import PasswordPool, PasswordProblem
|
|
19
|
+
from zipctl.cli.context import Context
|
|
20
|
+
from zipctl.cli.errors import EXIT_OK, CliError
|
|
21
|
+
from zipctl.cli.methods import (
|
|
22
|
+
ENCRYPTION_METHODS,
|
|
23
|
+
NO_ENCRYPTION,
|
|
24
|
+
compression_label,
|
|
25
|
+
method_of,
|
|
26
|
+
)
|
|
27
|
+
from zipctl.cli.output import printable
|
|
28
|
+
from zipctl.cryptography import wz_aes_stores_crc
|
|
29
|
+
from zipctl.zipfile.file import ZipFile
|
|
30
|
+
from zipctl.zipfile.info import ZipInfo
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_passwords(
|
|
34
|
+
zf: ZipFile, infos: Sequence[ZipInfo], pool: PasswordPool
|
|
35
|
+
) -> list[bytes | None]:
|
|
36
|
+
"""The password of every encrypted member (``None`` for plain ones).
|
|
37
|
+
|
|
38
|
+
Stops at the first member that cannot be unlocked: the output would be
|
|
39
|
+
incomplete, so there is no point asking for the rest.
|
|
40
|
+
"""
|
|
41
|
+
passwords: list[bytes | None] = []
|
|
42
|
+
for info in infos:
|
|
43
|
+
if not info.is_encrypted:
|
|
44
|
+
passwords.append(None)
|
|
45
|
+
continue
|
|
46
|
+
password = pool.resolve(zf, info)
|
|
47
|
+
if isinstance(password, PasswordProblem):
|
|
48
|
+
raise CliError(f"cannot read {printable(info.filename)}: {password.text}")
|
|
49
|
+
passwords.append(password)
|
|
50
|
+
return passwords
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _target_keeps_crc(target: Target) -> bool:
|
|
54
|
+
"""Whether a member written for *target* carries its CRC-32 (WZ-AES 2 does not)."""
|
|
55
|
+
if not target.method.is_aes:
|
|
56
|
+
return True
|
|
57
|
+
return wz_aes_stores_crc(
|
|
58
|
+
target.extra.force_wz_aes_version if target.extra else None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _can_copy_raw(info: ZipInfo, target: Target) -> bool:
|
|
63
|
+
"""Whether *info* can go to *target* without decompressing and compressing it.
|
|
64
|
+
|
|
65
|
+
When the CRC-32 is lost on the way (from or to WZ-AES 2) the data is read once
|
|
66
|
+
more to compute it, which a ZipCrypto member, being slow to decrypt, is not
|
|
67
|
+
worth: that copy takes the ordinary path.
|
|
68
|
+
"""
|
|
69
|
+
if info.stores_crc and _target_keeps_crc(target):
|
|
70
|
+
return True
|
|
71
|
+
return method_of(info) is not ENCRYPTION_METHODS["zipcrypto"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _checksum(src: ZipFile, info: ZipInfo, password: bytes | None) -> tuple[int, int]:
|
|
75
|
+
"""The CRC-32 and length of the data of *info*, read (and so checked) in full."""
|
|
76
|
+
crc = size = 0
|
|
77
|
+
with src.open(info, pwd=password) as reader:
|
|
78
|
+
while chunk := reader.read(CHUNK):
|
|
79
|
+
crc = zlib.crc32(chunk, crc)
|
|
80
|
+
size += len(chunk)
|
|
81
|
+
return crc, size
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _copy_compressed(
|
|
85
|
+
src: ZipFile,
|
|
86
|
+
dst: ZipFile,
|
|
87
|
+
info: ZipInfo,
|
|
88
|
+
new: ZipInfo,
|
|
89
|
+
password: bytes | None,
|
|
90
|
+
target: Target,
|
|
91
|
+
) -> tuple[int, int]:
|
|
92
|
+
"""Copy *info* with its compressed data as it is; returns its CRC-32 and size."""
|
|
93
|
+
if info.stores_crc and _target_keeps_crc(target):
|
|
94
|
+
crc, size = info.CRC, info.file_size # not read: --verify checks them
|
|
95
|
+
else:
|
|
96
|
+
crc, size = _checksum(src, info, password)
|
|
97
|
+
dst._copy_raw( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] # the library hook made for this copy
|
|
98
|
+
src,
|
|
99
|
+
info,
|
|
100
|
+
new,
|
|
101
|
+
crc=crc,
|
|
102
|
+
size=size,
|
|
103
|
+
pwd=password,
|
|
104
|
+
encryption=target.method.scheme,
|
|
105
|
+
password=target.password,
|
|
106
|
+
extra=target.extra,
|
|
107
|
+
)
|
|
108
|
+
return crc, size
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _recompress(
|
|
112
|
+
src: ZipFile,
|
|
113
|
+
dst: ZipFile,
|
|
114
|
+
info: ZipInfo,
|
|
115
|
+
new: ZipInfo,
|
|
116
|
+
password: bytes | None,
|
|
117
|
+
target: Target,
|
|
118
|
+
) -> tuple[int, int]:
|
|
119
|
+
"""Copy *info* by reading its data and compressing it again."""
|
|
120
|
+
crc = size = 0
|
|
121
|
+
with (
|
|
122
|
+
src.open(info, pwd=password) as reader,
|
|
123
|
+
dst.open(
|
|
124
|
+
new,
|
|
125
|
+
"w",
|
|
126
|
+
encryption=target.method.scheme,
|
|
127
|
+
password=target.password,
|
|
128
|
+
extra=target.extra,
|
|
129
|
+
) as writer,
|
|
130
|
+
):
|
|
131
|
+
while chunk := reader.read(CHUNK):
|
|
132
|
+
crc = zlib.crc32(chunk, crc)
|
|
133
|
+
size += len(chunk)
|
|
134
|
+
writer.write(chunk)
|
|
135
|
+
return crc, size
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _copy(
|
|
139
|
+
src: ZipFile,
|
|
140
|
+
dst: ZipFile,
|
|
141
|
+
infos: Sequence[ZipInfo],
|
|
142
|
+
passwords: Sequence[bytes | None],
|
|
143
|
+
targets: Sequence[Target],
|
|
144
|
+
compress_type: int | None,
|
|
145
|
+
level: int | None,
|
|
146
|
+
) -> list[Copied]:
|
|
147
|
+
copied: list[Copied] = []
|
|
148
|
+
for info, password, target in zip(infos, passwords, targets, strict=True):
|
|
149
|
+
new = ZipInfo(info.filename, info.date_time)
|
|
150
|
+
new.comment = info.comment
|
|
151
|
+
new.external_attr = info.external_attr
|
|
152
|
+
new.internal_attr = info.internal_attr
|
|
153
|
+
new.create_system = info.create_system
|
|
154
|
+
new.extra = info.carried_extra
|
|
155
|
+
if info.is_dir():
|
|
156
|
+
dst.mkdir(new)
|
|
157
|
+
copied.append(
|
|
158
|
+
Copied(info.filename, True, 0, 0, None, NO_ENCRYPTION, NO_ENCRYPTION)
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
new.compress_type = (
|
|
162
|
+
info.compress_type if compress_type is None else compress_type
|
|
163
|
+
)
|
|
164
|
+
new.compress_level = level
|
|
165
|
+
new.file_size = info.file_size
|
|
166
|
+
raw = compress_type is None and _can_copy_raw(info, target)
|
|
167
|
+
try:
|
|
168
|
+
if raw:
|
|
169
|
+
crc, size = _copy_compressed(src, dst, info, new, password, target)
|
|
170
|
+
else:
|
|
171
|
+
crc, size = _recompress(src, dst, info, new, password, target)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
raise CliError(
|
|
174
|
+
f"cannot copy {printable(info.filename)}: {describe_failure(exc)}"
|
|
175
|
+
) from None
|
|
176
|
+
copied.append(
|
|
177
|
+
Copied(
|
|
178
|
+
info.filename,
|
|
179
|
+
False,
|
|
180
|
+
size,
|
|
181
|
+
crc,
|
|
182
|
+
compression_label(new),
|
|
183
|
+
method_of(info),
|
|
184
|
+
target.method,
|
|
185
|
+
raw,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return copied
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_copy(
|
|
192
|
+
ctx: Context,
|
|
193
|
+
src: ZipFile,
|
|
194
|
+
passwords: Sequence[bytes | None],
|
|
195
|
+
targets: Sequence[Target],
|
|
196
|
+
job: CopyJob,
|
|
197
|
+
*,
|
|
198
|
+
verb: str,
|
|
199
|
+
compress_type: int | None = None,
|
|
200
|
+
level: int | None = None,
|
|
201
|
+
) -> int:
|
|
202
|
+
"""Copy every member of *src* per *targets* into ``job.output``, then report."""
|
|
203
|
+
infos = src.infolist()
|
|
204
|
+
with replacing(job.output, overwrite=job.force) as scratch:
|
|
205
|
+
with ZipFile(scratch, "w", strict_timestamps=False) as dst:
|
|
206
|
+
dst.comment = src.comment
|
|
207
|
+
copied = _copy(src, dst, infos, passwords, targets, compress_type, level)
|
|
208
|
+
if job.verify:
|
|
209
|
+
verify_copy(scratch, src, infos, targets, copied)
|
|
210
|
+
report_copy(ctx, job, verb, copied)
|
|
211
|
+
return EXIT_OK
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The command-line side of the copying commands: arguments and path checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from zipctl.cli.commands.helpers.output_options import (
|
|
11
|
+
OutputArgs,
|
|
12
|
+
OutputOptions,
|
|
13
|
+
add_output_options,
|
|
14
|
+
)
|
|
15
|
+
from zipctl.cli.errors import CliError, UsageError
|
|
16
|
+
from zipctl.cli.output import printable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CopyArgs(OutputArgs, Protocol):
|
|
20
|
+
"""What :func:`add_copy_options` leaves on the parsed arguments."""
|
|
21
|
+
|
|
22
|
+
input: str
|
|
23
|
+
output: str
|
|
24
|
+
force: bool
|
|
25
|
+
no_verify: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CopyJob:
|
|
30
|
+
"""What :func:`add_copy_options` collects: the files and how to report."""
|
|
31
|
+
|
|
32
|
+
input: str
|
|
33
|
+
output: str
|
|
34
|
+
force: bool
|
|
35
|
+
verify: bool
|
|
36
|
+
report: OutputOptions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def copy_job(args: CopyArgs) -> CopyJob:
|
|
40
|
+
return CopyJob(
|
|
41
|
+
args.input,
|
|
42
|
+
args.output,
|
|
43
|
+
args.force,
|
|
44
|
+
not args.no_verify,
|
|
45
|
+
OutputOptions.from_args(args),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_paths(job: CopyJob) -> None:
|
|
50
|
+
"""Refuse an output that is the input, or that exists without ``--force``."""
|
|
51
|
+
try:
|
|
52
|
+
same = os.path.samefile(job.input, job.output)
|
|
53
|
+
except OSError:
|
|
54
|
+
same = False
|
|
55
|
+
if same:
|
|
56
|
+
raise UsageError(
|
|
57
|
+
f"{printable(job.output)} is the input archive; zipctl never rewrites "
|
|
58
|
+
"an archive in place (write a new file, then move it over the original)",
|
|
59
|
+
)
|
|
60
|
+
if os.path.lexists(job.output) and not job.force:
|
|
61
|
+
raise CliError(
|
|
62
|
+
f"{printable(job.output)} already exists (use --force to replace it)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_copy_options(parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
"""The arguments every copying command takes."""
|
|
68
|
+
parser.add_argument("input", metavar="INPUT", help="the archive to read")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"output", metavar="OUTPUT", help="the new archive (never the input itself)"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--force", action="store_true", help="replace OUTPUT if it exists"
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--no-verify",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="skip reading OUTPUT back before moving it into place",
|
|
79
|
+
)
|
|
80
|
+
add_output_options(parser, verbose_help="list every member", quiet=True)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The report the copy commands print after a copy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zipctl.cli.commands.helpers.copying.options import CopyJob
|
|
6
|
+
from zipctl.cli.commands.helpers.copying.targets import Copied
|
|
7
|
+
from zipctl.cli.context import Context
|
|
8
|
+
from zipctl.cli.output import JsonValue, count, printable, write_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def report_copy(ctx: Context, job: CopyJob, verb: str, copied: list[Copied]) -> None:
|
|
12
|
+
files = [c for c in copied if not c.directory]
|
|
13
|
+
encrypted = sum(c.after.is_encrypted for c in files)
|
|
14
|
+
if job.report.json:
|
|
15
|
+
members: list[dict[str, JsonValue]] = [
|
|
16
|
+
{
|
|
17
|
+
"name": c.name,
|
|
18
|
+
"directory": c.directory,
|
|
19
|
+
"size": c.size,
|
|
20
|
+
"compression": c.compression,
|
|
21
|
+
"encryption_before": c.before.label,
|
|
22
|
+
"encryption_after": c.after.label,
|
|
23
|
+
}
|
|
24
|
+
for c in copied
|
|
25
|
+
]
|
|
26
|
+
write_json(
|
|
27
|
+
ctx.stdout,
|
|
28
|
+
{
|
|
29
|
+
"ok": True,
|
|
30
|
+
"input": job.input,
|
|
31
|
+
"output": job.output,
|
|
32
|
+
"verified": job.verify,
|
|
33
|
+
"file_count": len(files),
|
|
34
|
+
"directory_count": len(copied) - len(files),
|
|
35
|
+
"encrypted_count": encrypted,
|
|
36
|
+
"members": members,
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
if job.report.quiet:
|
|
41
|
+
return
|
|
42
|
+
if job.report.verbose:
|
|
43
|
+
for c in copied:
|
|
44
|
+
if c.directory:
|
|
45
|
+
ctx.out(f"Copying: {printable(c.name)} (directory)")
|
|
46
|
+
continue
|
|
47
|
+
change = c.after.label
|
|
48
|
+
if c.before != c.after:
|
|
49
|
+
change = f"{c.before.label} -> {change}"
|
|
50
|
+
ctx.out(f"Copying: {printable(c.name)} ({c.compression}, {change})")
|
|
51
|
+
if ctx.warned or (job.report.verbose and copied):
|
|
52
|
+
ctx.out()
|
|
53
|
+
parts = [
|
|
54
|
+
count(len(files), "file"),
|
|
55
|
+
count(len(copied) - len(files), "directory", "directories"),
|
|
56
|
+
]
|
|
57
|
+
if encrypted:
|
|
58
|
+
parts.append(f"{encrypted} encrypted")
|
|
59
|
+
verified = " (verified)" if job.verify else ""
|
|
60
|
+
ctx.out(
|
|
61
|
+
f"{verb} {printable(job.input)} into {printable(job.output)}: "
|
|
62
|
+
f"{', '.join(parts)}{verified}"
|
|
63
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""How each member of a copy is written, and the record of what was written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.errors import CliError
|
|
8
|
+
from zipctl.cli.methods import (
|
|
9
|
+
NO_ENCRYPTION,
|
|
10
|
+
UNKNOWN_AES,
|
|
11
|
+
EncryptionMethod,
|
|
12
|
+
extra_for,
|
|
13
|
+
method_of,
|
|
14
|
+
)
|
|
15
|
+
from zipctl.cli.output import printable
|
|
16
|
+
from zipctl.zipfile.file import ZipFileExtra
|
|
17
|
+
from zipctl.zipfile.info import ZipInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Target:
|
|
22
|
+
"""How one member is written: its encryption method, password and AES settings."""
|
|
23
|
+
|
|
24
|
+
method: EncryptionMethod = NO_ENCRYPTION
|
|
25
|
+
password: bytes | None = None
|
|
26
|
+
extra: ZipFileExtra | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PLAIN = Target()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def target_for_method(
|
|
33
|
+
method: EncryptionMethod, password: bytes | None, aes_version: int | None
|
|
34
|
+
) -> Target:
|
|
35
|
+
"""The target that writes with *method*, its *password* and AES version."""
|
|
36
|
+
return Target(
|
|
37
|
+
method,
|
|
38
|
+
password if method.is_encrypted else None,
|
|
39
|
+
extra_for(method, aes_version),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def keep_target(info: ZipInfo, password: bytes | None) -> Target:
|
|
44
|
+
"""The target that writes *info* again with the protection it already has."""
|
|
45
|
+
method = method_of(info)
|
|
46
|
+
if not method.is_encrypted:
|
|
47
|
+
return PLAIN
|
|
48
|
+
if method is UNKNOWN_AES:
|
|
49
|
+
raise CliError(
|
|
50
|
+
f"cannot keep the encryption of {printable(info.filename)}: unknown "
|
|
51
|
+
f"AES strength {info.aes_extra.wz_aes_strength}"
|
|
52
|
+
)
|
|
53
|
+
if not method.is_aes:
|
|
54
|
+
return Target(method, password)
|
|
55
|
+
extra = ZipFileExtra(
|
|
56
|
+
force_wz_aes_version=info.aes_extra.wz_aes_version, wz_aes_nbits=method.aes_bits
|
|
57
|
+
)
|
|
58
|
+
return Target(method, password, extra)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Copied:
|
|
63
|
+
"""What was written for one member."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
directory: bool
|
|
67
|
+
size: int
|
|
68
|
+
crc: int
|
|
69
|
+
compression: str | None
|
|
70
|
+
before: EncryptionMethod
|
|
71
|
+
after: EncryptionMethod
|
|
72
|
+
raw: bool = False # the compressed data was copied as it is
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def describe_failure(exc: Exception) -> str:
|
|
76
|
+
return printable(str(exc) or type(exc).__name__)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Reading a new archive back to prove it holds what was meant to be written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zlib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from zipctl.cli.archive import CHUNK
|
|
9
|
+
from zipctl.cli.commands.helpers.copying.targets import (
|
|
10
|
+
Copied,
|
|
11
|
+
Target,
|
|
12
|
+
describe_failure,
|
|
13
|
+
)
|
|
14
|
+
from zipctl.cli.errors import EXIT_FAILURE, CliError
|
|
15
|
+
from zipctl.cli.methods import method_of
|
|
16
|
+
from zipctl.cli.output import printable
|
|
17
|
+
from zipctl.zipfile.file import ZipFile
|
|
18
|
+
from zipctl.zipfile.info import ZipInfo
|
|
19
|
+
from zipctl.zipfile.shared import MASK_COMPRESS_OPTIONS
|
|
20
|
+
|
|
21
|
+
_SHOWN = 10 # problems listed before "and N more"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify_copy(
|
|
25
|
+
path: str,
|
|
26
|
+
src: ZipFile,
|
|
27
|
+
infos: Sequence[ZipInfo],
|
|
28
|
+
targets: Sequence[Target],
|
|
29
|
+
copied: Sequence[Copied],
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Read the new archive back; raise if it differs from what was intended."""
|
|
32
|
+
try:
|
|
33
|
+
out = ZipFile(path)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise CliError(
|
|
36
|
+
f"the new archive cannot be read back: {describe_failure(exc)}"
|
|
37
|
+
) from None
|
|
38
|
+
problems: list[str] = []
|
|
39
|
+
with out:
|
|
40
|
+
new_infos = out.infolist()
|
|
41
|
+
same_count = len(new_infos) == len(infos)
|
|
42
|
+
if not same_count:
|
|
43
|
+
problems.append(f"expected {len(infos)} members but found {len(new_infos)}")
|
|
44
|
+
if out.comment != src.comment:
|
|
45
|
+
problems.append("the archive comment differs")
|
|
46
|
+
if same_count:
|
|
47
|
+
for old, new, target, record in zip(
|
|
48
|
+
infos, new_infos, targets, copied, strict=True
|
|
49
|
+
):
|
|
50
|
+
problems.extend(_member_problems(out, old, new, target, record))
|
|
51
|
+
if problems:
|
|
52
|
+
shown = problems[:_SHOWN]
|
|
53
|
+
if len(problems) > _SHOWN:
|
|
54
|
+
shown.append(f"and {len(problems) - _SHOWN} more")
|
|
55
|
+
raise CliError(
|
|
56
|
+
"verification of the new archive failed; nothing was written",
|
|
57
|
+
EXIT_FAILURE,
|
|
58
|
+
tuple(shown),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _member_problems(
|
|
63
|
+
out: ZipFile, old: ZipInfo, new: ZipInfo, target: Target, record: Copied
|
|
64
|
+
) -> list[str]:
|
|
65
|
+
name = printable(old.filename)
|
|
66
|
+
if (
|
|
67
|
+
new.filename,
|
|
68
|
+
new.date_time,
|
|
69
|
+
new.external_attr,
|
|
70
|
+
new.internal_attr,
|
|
71
|
+
new.create_system,
|
|
72
|
+
new.comment,
|
|
73
|
+
new.carried_extra,
|
|
74
|
+
) != (
|
|
75
|
+
old.filename,
|
|
76
|
+
old.date_time,
|
|
77
|
+
old.external_attr,
|
|
78
|
+
old.internal_attr,
|
|
79
|
+
old.create_system,
|
|
80
|
+
old.comment,
|
|
81
|
+
old.carried_extra,
|
|
82
|
+
):
|
|
83
|
+
return [f"{name}: name, date, mode, comment or extra fields differ"]
|
|
84
|
+
if record.raw and (
|
|
85
|
+
new.compress_type,
|
|
86
|
+
new.flag_bits & MASK_COMPRESS_OPTIONS,
|
|
87
|
+
new.file_size,
|
|
88
|
+
) != (old.compress_type, old.flag_bits & MASK_COMPRESS_OPTIONS, old.file_size):
|
|
89
|
+
return [f"{name}: the compressed data was not copied as it was"]
|
|
90
|
+
found = method_of(new)
|
|
91
|
+
if found != target.method:
|
|
92
|
+
return [f"{name}: is {found.label}, not {target.method.label}"]
|
|
93
|
+
if old.is_dir():
|
|
94
|
+
return []
|
|
95
|
+
return _data_problems(out, new, target, record, name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _data_problems(
|
|
99
|
+
out: ZipFile, new: ZipInfo, target: Target, record: Copied, name: str
|
|
100
|
+
) -> list[str]:
|
|
101
|
+
crc = size = 0
|
|
102
|
+
try:
|
|
103
|
+
with out.open(new, pwd=target.password) as stream:
|
|
104
|
+
while chunk := stream.read(CHUNK):
|
|
105
|
+
crc = zlib.crc32(chunk, crc)
|
|
106
|
+
size += len(chunk)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
return [f"{name}: {describe_failure(exc)}"]
|
|
109
|
+
if size != record.size or crc != record.crc:
|
|
110
|
+
return [f"{name}: the data read back differs from the data copied"]
|
|
111
|
+
return []
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""What ``create`` prints, as text or as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.commands.helpers.output_options import OutputOptions
|
|
8
|
+
from zipctl.cli.context import Context
|
|
9
|
+
from zipctl.cli.methods import NO_ENCRYPTION
|
|
10
|
+
from zipctl.cli.output import JsonValue, count, human_size, printable, write_json
|
|
11
|
+
|
|
12
|
+
__all__ = ["Added", "Planned", "report_created", "report_dry_run"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Added:
|
|
17
|
+
"""A member as written to the archive."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
directory: bool
|
|
21
|
+
is_symlink: bool
|
|
22
|
+
size: int
|
|
23
|
+
compressed_size: int
|
|
24
|
+
compression: str | None
|
|
25
|
+
encryption: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Planned:
|
|
30
|
+
"""A member ``--dry-run`` says would be written; *encryption* is a label."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
directory: bool
|
|
34
|
+
is_symlink: bool
|
|
35
|
+
compression: str | None
|
|
36
|
+
encryption: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detail(member: Added | Planned) -> str:
|
|
40
|
+
"""What a listing says about *member*: its kind, compression and protection."""
|
|
41
|
+
if member.directory:
|
|
42
|
+
return "directory"
|
|
43
|
+
plain = member.encryption == NO_ENCRYPTION.label
|
|
44
|
+
if member.is_symlink:
|
|
45
|
+
return "symlink" if plain else f"symlink, {member.encryption}"
|
|
46
|
+
return f"{member.compression}, {member.encryption}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _warn_skipped(
|
|
50
|
+
ctx: Context, skipped: list[tuple[str, str]], unused: list[str]
|
|
51
|
+
) -> None:
|
|
52
|
+
for path, reason in skipped:
|
|
53
|
+
ctx.warn(f"skipping {printable(path)}: {reason}")
|
|
54
|
+
for pattern in unused:
|
|
55
|
+
ctx.warn(f"--exclude {printable(pattern)!r} matched nothing")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _skipped_json(
|
|
59
|
+
skipped: list[tuple[str, str]], unused: list[str]
|
|
60
|
+
) -> dict[str, JsonValue]:
|
|
61
|
+
return {
|
|
62
|
+
"skipped": [{"path": p, "reason": r} for p, r in skipped],
|
|
63
|
+
"unused_excludes": unused,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def report_dry_run(
|
|
68
|
+
ctx: Context,
|
|
69
|
+
archive: str,
|
|
70
|
+
members: list[Planned],
|
|
71
|
+
skipped: list[tuple[str, str]],
|
|
72
|
+
unused: list[str],
|
|
73
|
+
*,
|
|
74
|
+
appending: bool,
|
|
75
|
+
output: OutputOptions,
|
|
76
|
+
) -> None:
|
|
77
|
+
files = sum(not m.directory for m in members)
|
|
78
|
+
directories = len(members) - files
|
|
79
|
+
if output.json:
|
|
80
|
+
write_json(
|
|
81
|
+
ctx.stdout,
|
|
82
|
+
{
|
|
83
|
+
"ok": True,
|
|
84
|
+
"archive": archive,
|
|
85
|
+
"dry_run": True,
|
|
86
|
+
"appended": appending,
|
|
87
|
+
"file_count": files,
|
|
88
|
+
"directory_count": directories,
|
|
89
|
+
"members": members,
|
|
90
|
+
**_skipped_json(skipped, unused),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
if output.quiet:
|
|
95
|
+
return
|
|
96
|
+
_warn_skipped(ctx, skipped, unused)
|
|
97
|
+
for member in members:
|
|
98
|
+
ctx.out(f"Would add: {printable(member.name)} ({_detail(member)})")
|
|
99
|
+
if members or ctx.warned:
|
|
100
|
+
ctx.out()
|
|
101
|
+
parts = f"{count(files, 'file')}, {count(directories, 'directory', 'directories')}"
|
|
102
|
+
name = printable(archive)
|
|
103
|
+
verb = f"add to {name}" if appending else f"create {name}"
|
|
104
|
+
ctx.out(f"Dry run: would {verb}: {parts}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def report_created(
|
|
108
|
+
ctx: Context,
|
|
109
|
+
archive: str,
|
|
110
|
+
members: list[Added],
|
|
111
|
+
skipped: list[tuple[str, str]],
|
|
112
|
+
unused: list[str],
|
|
113
|
+
*,
|
|
114
|
+
appended: bool,
|
|
115
|
+
output: OutputOptions,
|
|
116
|
+
) -> None:
|
|
117
|
+
files = [m for m in members if not m.directory]
|
|
118
|
+
directories = len(members) - len(files)
|
|
119
|
+
original = sum(m.size for m in files)
|
|
120
|
+
stored = sum(m.compressed_size for m in files)
|
|
121
|
+
if output.json:
|
|
122
|
+
write_json(
|
|
123
|
+
ctx.stdout,
|
|
124
|
+
{
|
|
125
|
+
"ok": True,
|
|
126
|
+
"archive": archive,
|
|
127
|
+
"appended": appended,
|
|
128
|
+
"file_count": len(files),
|
|
129
|
+
"directory_count": directories,
|
|
130
|
+
"bytes_in": original,
|
|
131
|
+
"bytes_out": stored,
|
|
132
|
+
"members": members,
|
|
133
|
+
**_skipped_json(skipped, unused),
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
if output.quiet:
|
|
138
|
+
return
|
|
139
|
+
_warn_skipped(ctx, skipped, unused)
|
|
140
|
+
if output.verbose:
|
|
141
|
+
for member in members:
|
|
142
|
+
ctx.out(f"Adding: {printable(member.name)} ({_detail(member)})")
|
|
143
|
+
if ctx.warned or (output.verbose and members):
|
|
144
|
+
ctx.out()
|
|
145
|
+
encrypted = sum(m.encryption != NO_ENCRYPTION.label for m in files)
|
|
146
|
+
parts = [count(len(files), "file"), count(directories, "directory", "directories")]
|
|
147
|
+
if encrypted:
|
|
148
|
+
parts.append(f"{encrypted} encrypted")
|
|
149
|
+
size = f"{human_size(original)}, {human_size(stored)} compressed"
|
|
150
|
+
name = printable(archive)
|
|
151
|
+
if appended:
|
|
152
|
+
ctx.out(f"Added {', '.join(parts)} ({size}) to {name}")
|
|
153
|
+
else:
|
|
154
|
+
ctx.out(f"Created {name}: {', '.join(parts)} ({size})")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Encryption rules: the plan, the JSON spec and the command-line options."""
|