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,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."""