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,374 @@
|
|
|
1
|
+
"""The ``create`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from zipctl.cli.archive import open_archive
|
|
11
|
+
from zipctl.cli.atomic import replacing
|
|
12
|
+
from zipctl.cli.commands.helpers.collect import Collector, Entry
|
|
13
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command
|
|
14
|
+
from zipctl.cli.commands.helpers.copying.targets import PLAIN, Target, target_for_method
|
|
15
|
+
from zipctl.cli.commands.helpers.create_report import (
|
|
16
|
+
Added,
|
|
17
|
+
Planned,
|
|
18
|
+
report_created,
|
|
19
|
+
report_dry_run,
|
|
20
|
+
)
|
|
21
|
+
from zipctl.cli.commands.helpers.encryption.options import (
|
|
22
|
+
EncryptionArgs,
|
|
23
|
+
EncryptionOptions,
|
|
24
|
+
add_encryption_options,
|
|
25
|
+
build_plan,
|
|
26
|
+
)
|
|
27
|
+
from zipctl.cli.commands.helpers.encryption.plan import PlanAssignment, warn_if_weak
|
|
28
|
+
from zipctl.cli.commands.helpers.output_options import (
|
|
29
|
+
OutputArgs,
|
|
30
|
+
OutputOptions,
|
|
31
|
+
add_output_options,
|
|
32
|
+
)
|
|
33
|
+
from zipctl.cli.commands.helpers.passwords.options import add_password_options
|
|
34
|
+
from zipctl.cli.commands.helpers.progress import (
|
|
35
|
+
ProgressArgs,
|
|
36
|
+
ProgressRenderer,
|
|
37
|
+
StepReporter,
|
|
38
|
+
add_progress_option,
|
|
39
|
+
progress_renderer,
|
|
40
|
+
)
|
|
41
|
+
from zipctl.cli.context import Context
|
|
42
|
+
from zipctl.cli.errors import EXIT_OK, CliError, UsageError, os_error_text
|
|
43
|
+
from zipctl.cli.methods import (
|
|
44
|
+
COMPRESSION,
|
|
45
|
+
NO_ENCRYPTION,
|
|
46
|
+
compression_label,
|
|
47
|
+
method_of,
|
|
48
|
+
require_compression,
|
|
49
|
+
require_level,
|
|
50
|
+
)
|
|
51
|
+
from zipctl.cli.output import printable
|
|
52
|
+
from zipctl.zipfile.file import ZipFile
|
|
53
|
+
from zipctl.zipfile.info import ZipInfo
|
|
54
|
+
from zipctl.zipfile.shared import ZIP_MAX_COMMENT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CreateArgs(OutputArgs, EncryptionArgs, ProgressArgs, Protocol):
|
|
58
|
+
archive: str
|
|
59
|
+
paths: list[str]
|
|
60
|
+
chdir: str | None
|
|
61
|
+
exclude: list[str]
|
|
62
|
+
symlinks: str
|
|
63
|
+
dry_run: bool
|
|
64
|
+
force: bool
|
|
65
|
+
append: bool
|
|
66
|
+
compression: str
|
|
67
|
+
level: int | None
|
|
68
|
+
comment: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_options(args: CreateArgs) -> bytes | None:
|
|
72
|
+
"""Refuse bad options; the ``--comment`` comes back encoded."""
|
|
73
|
+
if args.chdir is not None and not os.path.isdir(args.chdir):
|
|
74
|
+
raise UsageError(f"-C: {printable(args.chdir)} is not a directory")
|
|
75
|
+
comment = None
|
|
76
|
+
if args.comment is not None:
|
|
77
|
+
try:
|
|
78
|
+
comment = args.comment.encode("utf-8")
|
|
79
|
+
except UnicodeEncodeError:
|
|
80
|
+
raise UsageError("--comment is not valid UTF-8") from None
|
|
81
|
+
if len(comment) > ZIP_MAX_COMMENT:
|
|
82
|
+
raise UsageError("--comment is too long (65535 bytes at most)")
|
|
83
|
+
require_compression(args.compression)
|
|
84
|
+
require_level(args.compression, args.level)
|
|
85
|
+
return comment
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe(zf: ZipFile, first: int) -> list[Added]:
|
|
89
|
+
return [
|
|
90
|
+
Added(
|
|
91
|
+
info.filename,
|
|
92
|
+
info.is_dir(),
|
|
93
|
+
info.is_symlink(),
|
|
94
|
+
info.file_size,
|
|
95
|
+
info.compress_size,
|
|
96
|
+
None if info.is_dir() else compression_label(info),
|
|
97
|
+
method_of(info).label,
|
|
98
|
+
)
|
|
99
|
+
for info in zf.infolist()[first:]
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_target(args: CreateArgs, entries: list[Entry]) -> tuple[bool, list[Entry]]:
|
|
104
|
+
"""Refuse an archive that may not be written.
|
|
105
|
+
|
|
106
|
+
Returns whether it is appended to, and the entries still to add: a
|
|
107
|
+
directory the archive already holds is not added again, but a file with
|
|
108
|
+
a name it already holds is refused.
|
|
109
|
+
"""
|
|
110
|
+
exists = os.path.lexists(args.archive)
|
|
111
|
+
if exists and not (args.force or args.append):
|
|
112
|
+
raise CliError(
|
|
113
|
+
f"{printable(args.archive)} already exists (use --force to replace it "
|
|
114
|
+
"or --append to add to it)"
|
|
115
|
+
)
|
|
116
|
+
if not (args.append and exists):
|
|
117
|
+
return False, entries
|
|
118
|
+
with open_archive(args.archive) as existing:
|
|
119
|
+
present = existing.NameToInfo
|
|
120
|
+
clashes = [e.arcname for e in entries if not e.is_dir and e.arcname in present]
|
|
121
|
+
new = [e for e in entries if e.arcname not in present]
|
|
122
|
+
if clashes:
|
|
123
|
+
listed = ", ".join(f"'{printable(name)}'" for name in clashes[:5])
|
|
124
|
+
more = f" and {len(clashes) - 5} more" if len(clashes) > 5 else ""
|
|
125
|
+
raise CliError(f"already in the archive: {listed}{more}")
|
|
126
|
+
return True, new
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class CreateJob:
|
|
131
|
+
"""What ``create`` writes and how; *targets* covers the files (not directories)."""
|
|
132
|
+
|
|
133
|
+
archive: str
|
|
134
|
+
entries: list[Entry]
|
|
135
|
+
targets: dict[str, Target]
|
|
136
|
+
appending: bool
|
|
137
|
+
overwrite: bool
|
|
138
|
+
compression: int
|
|
139
|
+
level: int | None
|
|
140
|
+
comment: bytes | None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_archive(job: CreateJob, renderer: ProgressRenderer | None) -> list[Added]:
|
|
144
|
+
"""Write the entries (into a copy, moved into place at the end)."""
|
|
145
|
+
with replacing(job.archive, overwrite=job.overwrite, seed=job.appending) as scratch:
|
|
146
|
+
with ZipFile(
|
|
147
|
+
scratch,
|
|
148
|
+
"a" if job.appending else "w",
|
|
149
|
+
compression=job.compression,
|
|
150
|
+
compresslevel=job.level,
|
|
151
|
+
strict_timestamps=False,
|
|
152
|
+
) as zf:
|
|
153
|
+
first = len(zf.infolist())
|
|
154
|
+
if job.comment is not None:
|
|
155
|
+
zf.comment = job.comment
|
|
156
|
+
_add_all(zf, job.entries, job.targets, renderer)
|
|
157
|
+
return _describe(zf, first)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _size(entry: Entry) -> int:
|
|
161
|
+
try:
|
|
162
|
+
return os.path.getsize(entry.source) if _has_content(entry) else 0
|
|
163
|
+
except OSError:
|
|
164
|
+
return 0 # a vanished file fails in _add_entry, with a proper message
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _add_all(
|
|
168
|
+
zf: ZipFile,
|
|
169
|
+
entries: list[Entry],
|
|
170
|
+
targets: dict[str, Target],
|
|
171
|
+
renderer: ProgressRenderer | None,
|
|
172
|
+
) -> None:
|
|
173
|
+
steps = StepReporter(renderer, ((e.arcname, _size(e)) for e in entries))
|
|
174
|
+
for index, entry in enumerate(entries):
|
|
175
|
+
steps.start(index)
|
|
176
|
+
try:
|
|
177
|
+
_add_entry(zf, entry, targets.get(entry.arcname, PLAIN))
|
|
178
|
+
except CliError:
|
|
179
|
+
steps.finish(index, ok=False)
|
|
180
|
+
raise
|
|
181
|
+
steps.finish(index, ok=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_create(args: CreateArgs, ctx: Context) -> int:
|
|
185
|
+
comment = _check_options(args)
|
|
186
|
+
encryption = EncryptionOptions.from_args(args)
|
|
187
|
+
output = OutputOptions.from_args(args)
|
|
188
|
+
plan = build_plan(encryption, ctx)
|
|
189
|
+
collector = Collector(args.chdir, args.archive, args.exclude, args.symlinks)
|
|
190
|
+
for given in args.paths:
|
|
191
|
+
collector.add_path(given)
|
|
192
|
+
appending, entries = _check_target(args, collector.entries)
|
|
193
|
+
|
|
194
|
+
assignment = plan.assign([e.arcname for e in entries if not e.is_dir])
|
|
195
|
+
if not output.quiet:
|
|
196
|
+
warn_if_weak(assignment.methods, ctx)
|
|
197
|
+
unused = collector.unused_excludes()
|
|
198
|
+
if args.dry_run:
|
|
199
|
+
planned = _preview(entries, assignment, args.compression)
|
|
200
|
+
report_dry_run(
|
|
201
|
+
ctx,
|
|
202
|
+
args.archive,
|
|
203
|
+
planned,
|
|
204
|
+
collector.skipped,
|
|
205
|
+
unused,
|
|
206
|
+
appending=appending,
|
|
207
|
+
output=output,
|
|
208
|
+
)
|
|
209
|
+
return EXIT_OK
|
|
210
|
+
plan.resolve_prompts(assignment, ctx)
|
|
211
|
+
|
|
212
|
+
job = CreateJob(
|
|
213
|
+
args.archive,
|
|
214
|
+
entries,
|
|
215
|
+
{
|
|
216
|
+
name: target_for_method(
|
|
217
|
+
rule.method, rule.password, encryption.wz_aes_version
|
|
218
|
+
)
|
|
219
|
+
for name, rule in assignment.chosen.items()
|
|
220
|
+
if rule is not None
|
|
221
|
+
},
|
|
222
|
+
appending=appending,
|
|
223
|
+
overwrite=args.force or args.append,
|
|
224
|
+
compression=COMPRESSION[args.compression],
|
|
225
|
+
level=args.level,
|
|
226
|
+
comment=comment,
|
|
227
|
+
)
|
|
228
|
+
with progress_renderer(ctx.stderr, args.progress) as renderer:
|
|
229
|
+
members = _write_archive(job, renderer)
|
|
230
|
+
report_created(
|
|
231
|
+
ctx,
|
|
232
|
+
args.archive,
|
|
233
|
+
members,
|
|
234
|
+
collector.skipped,
|
|
235
|
+
unused,
|
|
236
|
+
appended=appending,
|
|
237
|
+
output=output,
|
|
238
|
+
)
|
|
239
|
+
return EXIT_OK
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _has_content(entry: Entry) -> bool:
|
|
243
|
+
"""Whether *entry* holds file data to read from disk (not a directory or link)."""
|
|
244
|
+
return not entry.is_dir and entry.link_target is None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _add_link(zf: ZipFile, entry: Entry, link_target: str, target: Target) -> None:
|
|
248
|
+
"""Store the link itself: its target is the entry's data, stored, not squeezed.
|
|
249
|
+
|
|
250
|
+
The target is encrypted like any file's data, so a protected archive does
|
|
251
|
+
not give away where its links point.
|
|
252
|
+
"""
|
|
253
|
+
info = ZipInfo.from_file(
|
|
254
|
+
entry.source, entry.arcname, strict_timestamps=False, follow_symlinks=False
|
|
255
|
+
)
|
|
256
|
+
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
257
|
+
info.compress_type = COMPRESSION["store"]
|
|
258
|
+
zf.writestr(
|
|
259
|
+
info,
|
|
260
|
+
link_target,
|
|
261
|
+
encryption=target.method.scheme,
|
|
262
|
+
password=target.password,
|
|
263
|
+
extra=target.extra,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _add_entry(zf: ZipFile, entry: Entry, target: Target) -> None:
|
|
268
|
+
try:
|
|
269
|
+
if entry.link_target is not None:
|
|
270
|
+
_add_link(zf, entry, entry.link_target, target)
|
|
271
|
+
return
|
|
272
|
+
if entry.is_dir:
|
|
273
|
+
zf.write(entry.source, entry.arcname)
|
|
274
|
+
return
|
|
275
|
+
zf.write(
|
|
276
|
+
entry.source,
|
|
277
|
+
entry.arcname,
|
|
278
|
+
encryption=target.method.scheme,
|
|
279
|
+
password=target.password,
|
|
280
|
+
extra=target.extra,
|
|
281
|
+
)
|
|
282
|
+
except (OSError, ValueError, UnicodeError, RuntimeError) as exc:
|
|
283
|
+
reason = os_error_text(exc) if isinstance(exc, OSError) else str(exc)
|
|
284
|
+
raise CliError(
|
|
285
|
+
f"cannot add {printable(entry.source)}: {printable(reason)}"
|
|
286
|
+
) from None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _preview(
|
|
290
|
+
entries: list[Entry], assignment: PlanAssignment, compression: str
|
|
291
|
+
) -> list[Planned]:
|
|
292
|
+
"""What ``--dry-run`` would add: name, kind and protection of each entry."""
|
|
293
|
+
planned: list[Planned] = []
|
|
294
|
+
for entry in entries:
|
|
295
|
+
rule = assignment.chosen.get(entry.arcname)
|
|
296
|
+
method = rule.method if rule else NO_ENCRYPTION
|
|
297
|
+
link = entry.link_target is not None
|
|
298
|
+
planned.append(
|
|
299
|
+
Planned(
|
|
300
|
+
entry.arcname,
|
|
301
|
+
entry.is_dir,
|
|
302
|
+
link,
|
|
303
|
+
None if entry.is_dir else "store" if link else compression,
|
|
304
|
+
method.label,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return planned
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def register(subparsers: Subparsers) -> None:
|
|
311
|
+
parser = add_command(
|
|
312
|
+
subparsers,
|
|
313
|
+
"create",
|
|
314
|
+
cmd_create,
|
|
315
|
+
"create an archive from files and directories, optionally encrypted",
|
|
316
|
+
)
|
|
317
|
+
parser.add_argument("archive", metavar="ARCHIVE", help="the archive to create")
|
|
318
|
+
parser.add_argument(
|
|
319
|
+
"paths",
|
|
320
|
+
nargs="+",
|
|
321
|
+
metavar="PATH",
|
|
322
|
+
help="files and directories to add",
|
|
323
|
+
)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"-C",
|
|
326
|
+
"--directory",
|
|
327
|
+
dest="chdir",
|
|
328
|
+
metavar="DIR",
|
|
329
|
+
help="take PATHs relative to DIR",
|
|
330
|
+
)
|
|
331
|
+
parser.add_argument(
|
|
332
|
+
"--exclude",
|
|
333
|
+
action="append",
|
|
334
|
+
default=[],
|
|
335
|
+
metavar="GLOB",
|
|
336
|
+
help="leave out what matches GLOB. Repeatable",
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--symlinks",
|
|
340
|
+
choices=["follow", "store", "skip"],
|
|
341
|
+
default="follow",
|
|
342
|
+
metavar="MODE",
|
|
343
|
+
help="what to do with symbolic links: follow, store or skip. Default: follow",
|
|
344
|
+
)
|
|
345
|
+
add_progress_option(parser)
|
|
346
|
+
parser.add_argument(
|
|
347
|
+
"-n",
|
|
348
|
+
"--dry-run",
|
|
349
|
+
action="store_true",
|
|
350
|
+
help="list what would be added and write nothing",
|
|
351
|
+
)
|
|
352
|
+
mode = parser.add_mutually_exclusive_group()
|
|
353
|
+
mode.add_argument(
|
|
354
|
+
"--force", action="store_true", help="replace ARCHIVE if it exists"
|
|
355
|
+
)
|
|
356
|
+
mode.add_argument(
|
|
357
|
+
"--append",
|
|
358
|
+
action="store_true",
|
|
359
|
+
help="add to ARCHIVE if it exists",
|
|
360
|
+
)
|
|
361
|
+
parser.add_argument(
|
|
362
|
+
"--compression",
|
|
363
|
+
choices=list(COMPRESSION),
|
|
364
|
+
default="deflate",
|
|
365
|
+
metavar="METHOD",
|
|
366
|
+
help=f"compression method: {', '.join(COMPRESSION)}. Default: deflate",
|
|
367
|
+
)
|
|
368
|
+
parser.add_argument(
|
|
369
|
+
"-L", "--level", type=int, metavar="N", help="compression level"
|
|
370
|
+
)
|
|
371
|
+
parser.add_argument("--comment", metavar="TEXT", help="archive comment")
|
|
372
|
+
add_output_options(parser, verbose_help="list every member added", quiet=True)
|
|
373
|
+
add_encryption_options(parser)
|
|
374
|
+
add_password_options(parser)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The ``decrypt`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.archive import open_archive
|
|
8
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command
|
|
9
|
+
from zipctl.cli.commands.helpers.copying.copy import read_passwords, run_copy
|
|
10
|
+
from zipctl.cli.commands.helpers.copying.options import (
|
|
11
|
+
CopyArgs,
|
|
12
|
+
add_copy_options,
|
|
13
|
+
check_paths,
|
|
14
|
+
copy_job,
|
|
15
|
+
)
|
|
16
|
+
from zipctl.cli.commands.helpers.copying.targets import PLAIN, keep_target
|
|
17
|
+
from zipctl.cli.commands.helpers.passwords.options import (
|
|
18
|
+
PasswordArgs,
|
|
19
|
+
PasswordOptions,
|
|
20
|
+
add_password_options,
|
|
21
|
+
password_pool,
|
|
22
|
+
)
|
|
23
|
+
from zipctl.cli.commands.helpers.selection import select
|
|
24
|
+
from zipctl.cli.context import Context
|
|
25
|
+
from zipctl.cli.errors import CliError
|
|
26
|
+
from zipctl.cli.output import printable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DecryptArgs(CopyArgs, PasswordArgs, Protocol):
|
|
30
|
+
match: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_decrypt(args: DecryptArgs, ctx: Context) -> int:
|
|
34
|
+
job = copy_job(args)
|
|
35
|
+
check_paths(job)
|
|
36
|
+
with open_archive(job.input) as src:
|
|
37
|
+
infos = src.infolist()
|
|
38
|
+
encrypted = [info.filename for info in infos if info.is_encrypted]
|
|
39
|
+
if not encrypted:
|
|
40
|
+
raise CliError(f"{printable(job.input)} has no encrypted members")
|
|
41
|
+
chosen = select(encrypted, args.match, "encrypted member")
|
|
42
|
+
passwords = read_passwords(
|
|
43
|
+
src, infos, password_pool(PasswordOptions.from_args(args), ctx)
|
|
44
|
+
)
|
|
45
|
+
targets = [
|
|
46
|
+
PLAIN
|
|
47
|
+
if not info.is_encrypted or info.filename in chosen
|
|
48
|
+
else keep_target(info, password)
|
|
49
|
+
for info, password in zip(infos, passwords, strict=True)
|
|
50
|
+
]
|
|
51
|
+
return run_copy(ctx, src, passwords, targets, job, verb="Decrypted")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register(subparsers: Subparsers) -> None:
|
|
55
|
+
parser = add_command(
|
|
56
|
+
subparsers,
|
|
57
|
+
"decrypt",
|
|
58
|
+
cmd_decrypt,
|
|
59
|
+
"copy an encrypted archive into a new, unencrypted one",
|
|
60
|
+
)
|
|
61
|
+
add_copy_options(parser)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--match",
|
|
64
|
+
action="append",
|
|
65
|
+
default=[],
|
|
66
|
+
metavar="GLOB",
|
|
67
|
+
help="Decrypt only members matching GLOB, leave the others encrypted. "
|
|
68
|
+
"Repeatable",
|
|
69
|
+
)
|
|
70
|
+
add_password_options(parser)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The ``encrypt`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.archive import open_archive
|
|
8
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command
|
|
9
|
+
from zipctl.cli.commands.helpers.copying.copy import run_copy
|
|
10
|
+
from zipctl.cli.commands.helpers.copying.options import (
|
|
11
|
+
CopyArgs,
|
|
12
|
+
add_copy_options,
|
|
13
|
+
check_paths,
|
|
14
|
+
copy_job,
|
|
15
|
+
)
|
|
16
|
+
from zipctl.cli.commands.helpers.copying.targets import PLAIN, target_for_method
|
|
17
|
+
from zipctl.cli.commands.helpers.encryption.options import (
|
|
18
|
+
add_wz_aes_version,
|
|
19
|
+
require_aes_for_version,
|
|
20
|
+
)
|
|
21
|
+
from zipctl.cli.commands.helpers.encryption.plan import warn_if_weak
|
|
22
|
+
from zipctl.cli.commands.helpers.passwords.options import (
|
|
23
|
+
PasswordArgs,
|
|
24
|
+
PasswordOptions,
|
|
25
|
+
add_password_options,
|
|
26
|
+
given_password,
|
|
27
|
+
)
|
|
28
|
+
from zipctl.cli.commands.helpers.passwords.sources import (
|
|
29
|
+
prompt_new_password,
|
|
30
|
+
require_tty_for_prompt,
|
|
31
|
+
)
|
|
32
|
+
from zipctl.cli.commands.helpers.selection import select
|
|
33
|
+
from zipctl.cli.context import Context
|
|
34
|
+
from zipctl.cli.errors import CliError
|
|
35
|
+
from zipctl.cli.methods import ENCRYPTABLE, ENCRYPTION_METHODS
|
|
36
|
+
from zipctl.cli.output import count, printable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EncryptArgs(CopyArgs, PasswordArgs, Protocol):
|
|
40
|
+
encryption: str
|
|
41
|
+
match: list[str]
|
|
42
|
+
wz_aes_version: int | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_encrypt(args: EncryptArgs, ctx: Context) -> int:
|
|
46
|
+
job = copy_job(args)
|
|
47
|
+
passwords = PasswordOptions.from_args(args)
|
|
48
|
+
check_paths(job)
|
|
49
|
+
method = ENCRYPTION_METHODS[args.encryption]
|
|
50
|
+
require_aes_for_version(args.wz_aes_version, [method])
|
|
51
|
+
if passwords.prompt:
|
|
52
|
+
require_tty_for_prompt(ctx)
|
|
53
|
+
with open_archive(job.input) as src:
|
|
54
|
+
infos = src.infolist()
|
|
55
|
+
already = [info for info in infos if info.is_encrypted]
|
|
56
|
+
if already:
|
|
57
|
+
raise CliError(
|
|
58
|
+
f"{count(len(already), 'member')} of {printable(job.input)} "
|
|
59
|
+
f"already encrypted (first: {printable(already[0].filename)}); use "
|
|
60
|
+
"'zipctl rewrite' to change how members are protected"
|
|
61
|
+
)
|
|
62
|
+
files = [info.filename for info in infos if not info.is_dir()]
|
|
63
|
+
chosen = select(files, args.match, "file")
|
|
64
|
+
if not chosen:
|
|
65
|
+
raise CliError(f"{printable(job.input)} has no files to encrypt")
|
|
66
|
+
password = given_password(passwords, ctx)
|
|
67
|
+
if not job.report.quiet:
|
|
68
|
+
warn_if_weak([method], ctx)
|
|
69
|
+
if password is None:
|
|
70
|
+
password = prompt_new_password("Password", ctx)
|
|
71
|
+
target = target_for_method(method, password, args.wz_aes_version)
|
|
72
|
+
targets = [
|
|
73
|
+
target if not info.is_dir() and info.filename in chosen else PLAIN
|
|
74
|
+
for info in infos
|
|
75
|
+
]
|
|
76
|
+
return run_copy(ctx, src, [None] * len(infos), targets, job, verb="Encrypted")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register(subparsers: Subparsers) -> None:
|
|
80
|
+
parser = add_command(
|
|
81
|
+
subparsers,
|
|
82
|
+
"encrypt",
|
|
83
|
+
cmd_encrypt,
|
|
84
|
+
"copy an unencrypted archive into a new, encrypted one",
|
|
85
|
+
)
|
|
86
|
+
add_copy_options(parser)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--encryption",
|
|
89
|
+
choices=ENCRYPTABLE,
|
|
90
|
+
default="aes256",
|
|
91
|
+
metavar="METHOD",
|
|
92
|
+
help=f"Encryption method: {', '.join(ENCRYPTABLE)}. Default: aes256",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--match",
|
|
96
|
+
action="append",
|
|
97
|
+
default=[],
|
|
98
|
+
metavar="GLOB",
|
|
99
|
+
help="Encrypt only members matching GLOB, leave the others plain. Repeatable",
|
|
100
|
+
)
|
|
101
|
+
add_wz_aes_version(parser)
|
|
102
|
+
add_password_options(parser)
|