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.
Files changed (91) hide show
  1. zipctl/__init__.py +108 -0
  2. zipctl/__main__.py +5 -0
  3. zipctl/cli/__init__.py +108 -0
  4. zipctl/cli/archive.py +23 -0
  5. zipctl/cli/atomic.py +77 -0
  6. zipctl/cli/commands/__init__.py +8 -0
  7. zipctl/cli/commands/check_password.py +133 -0
  8. zipctl/cli/commands/create.py +374 -0
  9. zipctl/cli/commands/decrypt.py +70 -0
  10. zipctl/cli/commands/encrypt.py +102 -0
  11. zipctl/cli/commands/extract.py +262 -0
  12. zipctl/cli/commands/helpers/__init__.py +1 -0
  13. zipctl/cli/commands/helpers/collect.py +225 -0
  14. zipctl/cli/commands/helpers/command.py +119 -0
  15. zipctl/cli/commands/helpers/copying/__init__.py +1 -0
  16. zipctl/cli/commands/helpers/copying/copy.py +211 -0
  17. zipctl/cli/commands/helpers/copying/options.py +80 -0
  18. zipctl/cli/commands/helpers/copying/report.py +63 -0
  19. zipctl/cli/commands/helpers/copying/targets.py +76 -0
  20. zipctl/cli/commands/helpers/copying/verify.py +111 -0
  21. zipctl/cli/commands/helpers/create_report.py +154 -0
  22. zipctl/cli/commands/helpers/encryption/__init__.py +1 -0
  23. zipctl/cli/commands/helpers/encryption/options.py +192 -0
  24. zipctl/cli/commands/helpers/encryption/plan.py +119 -0
  25. zipctl/cli/commands/helpers/encryption/spec.py +256 -0
  26. zipctl/cli/commands/helpers/extract_report.py +148 -0
  27. zipctl/cli/commands/helpers/output_options.py +55 -0
  28. zipctl/cli/commands/helpers/passwords/__init__.py +1 -0
  29. zipctl/cli/commands/helpers/passwords/options.py +143 -0
  30. zipctl/cli/commands/helpers/passwords/pool.py +116 -0
  31. zipctl/cli/commands/helpers/passwords/sources.py +144 -0
  32. zipctl/cli/commands/helpers/policy_options.py +64 -0
  33. zipctl/cli/commands/helpers/progress.py +215 -0
  34. zipctl/cli/commands/helpers/selection.py +96 -0
  35. zipctl/cli/commands/helpers/sources.py +32 -0
  36. zipctl/cli/commands/inspect.py +145 -0
  37. zipctl/cli/commands/list.py +166 -0
  38. zipctl/cli/commands/policy.py +104 -0
  39. zipctl/cli/commands/rewrite.py +113 -0
  40. zipctl/cli/commands/test.py +154 -0
  41. zipctl/cli/context.py +57 -0
  42. zipctl/cli/errors.py +58 -0
  43. zipctl/cli/formatter.py +62 -0
  44. zipctl/cli/methods.py +136 -0
  45. zipctl/cli/output.py +138 -0
  46. zipctl/cli/parser.py +197 -0
  47. zipctl/compression/__init__.py +129 -0
  48. zipctl/compression/bz2.py +99 -0
  49. zipctl/compression/deflate.py +136 -0
  50. zipctl/compression/lzma.py +238 -0
  51. zipctl/compression/methods.py +176 -0
  52. zipctl/compression/stored.py +14 -0
  53. zipctl/compression/zstd.py +141 -0
  54. zipctl/cryptography/__init__.py +41 -0
  55. zipctl/cryptography/aes.py +500 -0
  56. zipctl/cryptography/base.py +163 -0
  57. zipctl/cryptography/zipcrypto.py +251 -0
  58. zipctl/exceptions.py +39 -0
  59. zipctl/py.typed +0 -0
  60. zipctl/zipfile/__init__.py +1 -0
  61. zipctl/zipfile/assessment.py +41 -0
  62. zipctl/zipfile/assessor.py +151 -0
  63. zipctl/zipfile/exceptions.py +27 -0
  64. zipctl/zipfile/ext.py +782 -0
  65. zipctl/zipfile/extract.py +186 -0
  66. zipctl/zipfile/file.py +1500 -0
  67. zipctl/zipfile/info.py +1127 -0
  68. zipctl/zipfile/inspection.py +127 -0
  69. zipctl/zipfile/io_wrappers.py +193 -0
  70. zipctl/zipfile/materialize.py +344 -0
  71. zipctl/zipfile/password.py +87 -0
  72. zipctl/zipfile/path/__init__.py +523 -0
  73. zipctl/zipfile/path/glob.py +104 -0
  74. zipctl/zipfile/policy_config.py +426 -0
  75. zipctl/zipfile/policy_extraction.py +267 -0
  76. zipctl/zipfile/progress.py +133 -0
  77. zipctl/zipfile/records.py +504 -0
  78. zipctl/zipfile/secure_fs.py +128 -0
  79. zipctl/zipfile/shared.py +104 -0
  80. zipctl/zipfile/validators.py +392 -0
  81. zipctl/zipfile/write.py +311 -0
  82. zipctl/zipfile/write_coordinator.py +111 -0
  83. zipctl-0.1.0.dist-info/METADATA +730 -0
  84. zipctl-0.1.0.dist-info/RECORD +91 -0
  85. zipctl-0.1.0.dist-info/WHEEL +4 -0
  86. zipctl-0.1.0.dist-info/entry_points.txt +2 -0
  87. zipctl-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zipctl-0.1.0.dist-info/licenses/NOTICE +32 -0
  89. zipctl-0.1.0.dist-info/licenses/licenses/CPYTHON-3.14.3.txt +277 -0
  90. zipctl-0.1.0.dist-info/licenses/licenses/JetBrainsMono-OFL.txt +93 -0
  91. 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)