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,192 @@
1
+ """The encryption options of ``create`` and ``rewrite``, and the plan they build."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ from zipctl.cli.commands.helpers.encryption.plan import EncryptionPlan, Rule
11
+ from zipctl.cli.commands.helpers.encryption.spec import plan_from_spec
12
+ from zipctl.cli.commands.helpers.passwords.options import (
13
+ PasswordArgs,
14
+ PasswordOptions,
15
+ given_password,
16
+ )
17
+ from zipctl.cli.commands.helpers.passwords.sources import (
18
+ readers_for,
19
+ require_tty_for_prompt,
20
+ )
21
+ from zipctl.cli.commands.helpers.sources import read_text_source
22
+ from zipctl.cli.context import Context
23
+ from zipctl.cli.errors import UsageError
24
+ from zipctl.cli.methods import (
25
+ ENCRYPTION_METHODS,
26
+ METHOD_CHOICES,
27
+ NO_ENCRYPTION,
28
+ EncryptionMethod,
29
+ )
30
+ from zipctl.cli.output import did_you_mean, printable
31
+ from zipctl.cryptography import WZ_AES_DEFAULT_VERSION
32
+
33
+
34
+ class EncryptionArgs(PasswordArgs, Protocol):
35
+ """What :func:`add_encryption_options` leaves on the parsed arguments."""
36
+
37
+ encryption: str | None
38
+ protect: list[str]
39
+ encryption_spec: str | None
40
+ wz_aes_version: int | None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class EncryptionOptions:
45
+ """The options of :func:`add_encryption_options`, with the password options."""
46
+
47
+ encryption: str | None
48
+ protect: list[str]
49
+ spec: str | None
50
+ wz_aes_version: int | None
51
+ passwords: PasswordOptions
52
+
53
+ @classmethod
54
+ def from_args(cls, args: EncryptionArgs) -> EncryptionOptions:
55
+ return cls(
56
+ args.encryption,
57
+ args.protect,
58
+ args.encryption_spec,
59
+ args.wz_aes_version,
60
+ PasswordOptions.from_args(args),
61
+ )
62
+
63
+
64
+ def plan_from_options(options: EncryptionOptions, ctx: Context) -> EncryptionPlan:
65
+ """The plan for ``--encryption`` and ``--protect`` (passwords come later)."""
66
+ rules: list[Rule] = []
67
+ for text in options.protect:
68
+ pattern, name = _split_protect(text)
69
+ if not pattern:
70
+ raise UsageError("--protect needs a pattern")
71
+ method = ENCRYPTION_METHODS[name]
72
+ typed = method.is_encrypted
73
+ rules.append(
74
+ Rule(
75
+ pattern,
76
+ method,
77
+ prompt=f"Password for {pattern}" if typed else None,
78
+ hint=(
79
+ f"the password for {printable(pattern)} can only be typed at a "
80
+ "terminal; use --encryption-spec to script it"
81
+ if typed
82
+ else None
83
+ ),
84
+ )
85
+ )
86
+ default = options.encryption
87
+ passwords = options.passwords
88
+ sources = passwords.file is not None or passwords.stdin
89
+ if default in (None, "none"):
90
+ if sources or passwords.prompt:
91
+ raise UsageError(
92
+ "a password option was given but --encryption names nothing to "
93
+ "protect with it (--protect rules ask for their own passwords)",
94
+ )
95
+ if default == "none":
96
+ rules.append(Rule(None, NO_ENCRYPTION))
97
+ else:
98
+ if passwords.prompt:
99
+ require_tty_for_prompt(ctx)
100
+ password = given_password(passwords, ctx)
101
+ rules.append(
102
+ Rule(
103
+ None,
104
+ ENCRYPTION_METHODS[default],
105
+ password=password,
106
+ prompt=None if password else "Password",
107
+ )
108
+ )
109
+ return EncryptionPlan(rules)
110
+
111
+
112
+ def _split_protect(text: str) -> tuple[str, str]:
113
+ """Split ``GLOB[=METHOD]``; the method is only taken if it is a known one.
114
+
115
+ A suffix that is not a method but nearly is (``=aes265``) is a typo, not
116
+ part of the glob.
117
+ """
118
+ pattern, _, method = text.rpartition("=")
119
+ if pattern and method in ENCRYPTION_METHODS:
120
+ return pattern, method
121
+ guess = did_you_mean(method, list(ENCRYPTION_METHODS)) if pattern else ""
122
+ if guess:
123
+ raise UsageError(
124
+ f"--protect {printable(text)!r}: unknown method {printable(method)!r}"
125
+ f"{guess} (choose from {METHOD_CHOICES})",
126
+ )
127
+ return text, "aes256"
128
+
129
+
130
+ def build_plan(options: EncryptionOptions, ctx: Context) -> EncryptionPlan:
131
+ """The plan for ``--encryption``/``--protect`` or for ``--encryption-spec``."""
132
+ if options.spec is None:
133
+ plan = plan_from_options(options, ctx)
134
+ else:
135
+ if options.encryption is not None or options.protect:
136
+ raise UsageError(
137
+ "--encryption-spec cannot be combined with --encryption or --protect; "
138
+ "put those rules in the spec",
139
+ )
140
+ passwords = options.passwords
141
+ if passwords.file is not None or passwords.stdin or passwords.prompt:
142
+ raise UsageError(
143
+ "password options do not apply with --encryption-spec; the spec says "
144
+ "where each password comes from",
145
+ )
146
+ text = read_text_source(options.spec, ctx, "encryption spec")
147
+ plan = plan_from_spec(text, options.spec, readers_for(ctx))
148
+ require_aes_for_version(options.wz_aes_version, plan.methods)
149
+ return plan
150
+
151
+
152
+ def require_aes_for_version(
153
+ version: int | None, methods: Iterable[EncryptionMethod]
154
+ ) -> None:
155
+ """Refuse ``--wz-aes-version`` when none of *methods* is an AES one."""
156
+ if version is not None and not any(method.is_aes for method in methods):
157
+ raise UsageError("--wz-aes-version only applies to AES encryption")
158
+
159
+
160
+ def add_wz_aes_version(parser: argparse.ArgumentParser) -> None:
161
+ """Add ``--wz-aes-version`` to *parser*."""
162
+ parser.add_argument(
163
+ "--wz-aes-version",
164
+ type=int,
165
+ choices=(1, 2),
166
+ metavar="VERSION",
167
+ help=f"WinZip AES version to write: 1 or 2. Default: {WZ_AES_DEFAULT_VERSION}",
168
+ )
169
+
170
+
171
+ def add_encryption_options(parser: argparse.ArgumentParser) -> None:
172
+ """Add the encryption options: method, per-member rules, spec file, AES version."""
173
+ parser.add_argument(
174
+ "--encryption",
175
+ metavar="METHOD",
176
+ choices=list(ENCRYPTION_METHODS),
177
+ help=f"Encryption method: {METHOD_CHOICES}",
178
+ )
179
+ parser.add_argument(
180
+ "--protect",
181
+ action="append",
182
+ default=[],
183
+ metavar="GLOB[=METHOD]",
184
+ help="Encrypt members matching GLOB, or leave them plain with GLOB=none. "
185
+ "Repeatable. Default: aes256",
186
+ )
187
+ parser.add_argument(
188
+ "--encryption-spec",
189
+ metavar="FILE",
190
+ help="JSON file with the rules and password references ('-' is standard input)",
191
+ )
192
+ add_wz_aes_version(parser)
@@ -0,0 +1,119 @@
1
+ """The rules that decide how each member is protected."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from zipctl.cli.commands.helpers.passwords.sources import (
9
+ prompt_new_password,
10
+ prompt_password,
11
+ )
12
+ from zipctl.cli.commands.helpers.selection import glob_matcher
13
+ from zipctl.cli.context import Context
14
+ from zipctl.cli.errors import UsageError
15
+ from zipctl.cli.methods import EncryptionMethod
16
+ from zipctl.cli.output import printable
17
+ from zipctl.cryptography import ZIP_CRYPTO
18
+
19
+
20
+ @dataclass(eq=False) # compared by identity: two equal rules are still two rules
21
+ class Rule:
22
+ """One line of the plan: members matching *pattern* get *method*.
23
+
24
+ *pattern* is ``None`` for the default rule, which matches every member.
25
+ A rule whose password is to be typed keeps its prompt in *prompt* until
26
+ :meth:`EncryptionPlan.resolve_prompts` asks for it; *hint* is what to say
27
+ when there is no terminal to ask at.
28
+ """
29
+
30
+ pattern: str | None
31
+ method: EncryptionMethod
32
+ password: bytes | None = None
33
+ prompt: str | None = None
34
+ hint: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PlanAssignment:
39
+ """The rule chosen for each member (``None``: no rule covers it)."""
40
+
41
+ chosen: dict[str, Rule | None]
42
+
43
+ @property
44
+ def used(self) -> set[Rule]:
45
+ """Every rule that decides at least one member."""
46
+ return {rule for rule in self.chosen.values() if rule is not None}
47
+
48
+ @property
49
+ def methods(self) -> set[EncryptionMethod]:
50
+ return {rule.method for rule in self.chosen.values() if rule is not None}
51
+
52
+
53
+ class EncryptionPlan:
54
+ """An ordered list of :class:`Rule`; the default rule (if any) comes last."""
55
+
56
+ def __init__(self, rules: Iterable[Rule] = ()) -> None:
57
+ self.rules: list[Rule] = list(rules)
58
+
59
+ @property
60
+ def methods(self) -> set[EncryptionMethod]:
61
+ return {rule.method for rule in self.rules}
62
+
63
+ def assign(self, names: Sequence[str]) -> PlanAssignment:
64
+ """Pick a rule for each of *names*, refusing rules that would do nothing.
65
+
66
+ A typo in a pattern must not silently leave files unprotected, so every
67
+ non-default rule has to be the deciding one for at least one member.
68
+ """
69
+ matchers = [
70
+ (rule, None if rule.pattern is None else glob_matcher(rule.pattern))
71
+ for rule in self.rules
72
+ ]
73
+ chosen: dict[str, Rule | None] = {}
74
+ for name in names:
75
+ chosen[name] = next(
76
+ (
77
+ rule
78
+ for rule, matches in matchers
79
+ if matches is None or matches(name)
80
+ ),
81
+ None,
82
+ )
83
+ assignment = PlanAssignment(chosen)
84
+ used = assignment.used
85
+ idle = [
86
+ rule for rule in self.rules if rule.pattern is not None and rule not in used
87
+ ]
88
+ if idle:
89
+ listed = ", ".join(f"'{printable(rule.pattern or '')}'" for rule in idle)
90
+ raise UsageError(
91
+ f"no member is decided by the rule for {listed} (nothing matches, "
92
+ "or an earlier rule already covers everything it matches)",
93
+ )
94
+ return assignment
95
+
96
+ def resolve_prompts(
97
+ self,
98
+ assignment: PlanAssignment,
99
+ ctx: Context,
100
+ prompt: Callable[[str], str] = prompt_password,
101
+ ) -> None:
102
+ """Ask for the passwords of the used rules that are still missing one."""
103
+ used = assignment.used
104
+ for rule in self.rules:
105
+ if rule.prompt is not None and rule.password is None and rule in used:
106
+ rule.password = prompt_new_password(
107
+ rule.prompt,
108
+ ctx,
109
+ hint=rule.hint,
110
+ prompt=prompt,
111
+ )
112
+
113
+
114
+ def warn_if_weak(methods: Iterable[EncryptionMethod], ctx: Context) -> None:
115
+ if any(method.scheme == ZIP_CRYPTO for method in methods):
116
+ ctx.warn(
117
+ "ZipCrypto is a weak legacy cipher; prefer aes256 "
118
+ "unless a tool needs ZipCrypto"
119
+ )
@@ -0,0 +1,256 @@
1
+ """The JSON encryption spec: rules whose passwords are references, not values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from typing import cast
8
+
9
+ from zipctl.cli.commands.helpers.encryption.plan import EncryptionPlan, Rule
10
+ from zipctl.cli.commands.helpers.passwords.sources import (
11
+ PasswordReaders,
12
+ password_bytes,
13
+ )
14
+ from zipctl.cli.commands.helpers.selection import glob_matcher
15
+ from zipctl.cli.errors import CliError, UsageError
16
+ from zipctl.cli.methods import ENCRYPTION_METHODS, METHOD_CHOICES
17
+ from zipctl.cli.output import did_you_mean, printable
18
+
19
+ _SPEC_KEYS = ("version", "default", "rules")
20
+ _RULE_KEYS = ("match", "method", "password")
21
+ _DEFAULT_KEYS = ("method", "password")
22
+ _REFERENCES = ("env", "file", "prompt", "stdin")
23
+
24
+
25
+ class _Issues:
26
+ def __init__(self) -> None:
27
+ self.items: list[str] = []
28
+
29
+ def add(self, path: str, message: str) -> None:
30
+ self.items.append(f"{path or '<spec>'}: {message}")
31
+
32
+
33
+ def _as_object(data: object) -> dict[str, object] | None:
34
+ """*data* as a JSON object, whose keys ``json.loads`` makes strings."""
35
+ return cast("dict[str, object]", data) if isinstance(data, dict) else None
36
+
37
+
38
+ def _check_keys(
39
+ data: dict[str, object], known: Sequence[str], path: str, issues: _Issues
40
+ ) -> None:
41
+ for key in data:
42
+ if key not in known:
43
+ issues.add(path, f"unknown key {key!r}{did_you_mean(key, known)}")
44
+
45
+
46
+ def _reference_entry(
47
+ data: object, path: str, issues: _Issues
48
+ ) -> tuple[str, object] | None:
49
+ """The ``(kind, value)`` a password reference names, or ``None`` if malformed."""
50
+ reference = _as_object(data)
51
+ if reference is None:
52
+ issues.add(path, 'expected an object like {"env": "NAME"}')
53
+ return None
54
+ if "value" in reference or "password" in reference:
55
+ issues.add(
56
+ path, "inline passwords are not allowed; use env, file, prompt or stdin"
57
+ )
58
+ return None
59
+ if len(reference) != 1 or next(iter(reference)) not in _REFERENCES:
60
+ issues.add(path, f"expected exactly one of: {', '.join(_REFERENCES)}")
61
+ return None
62
+ ((kind, value),) = reference.items()
63
+ return kind, value
64
+
65
+
66
+ def _stdin_password(
67
+ value: object,
68
+ path: str,
69
+ readers: PasswordReaders,
70
+ issues: _Issues,
71
+ stdin_uses: list[str],
72
+ ) -> bytes | None:
73
+ if value is not True:
74
+ issues.add(f"{path}.stdin", "must be true")
75
+ return None
76
+ if stdin_uses:
77
+ issues.add(path, "standard input can only be used by one password")
78
+ return None
79
+ stdin_uses.append(path)
80
+ try:
81
+ return readers.stdin()
82
+ except CliError as exc:
83
+ issues.add(path, exc.message)
84
+ return None
85
+
86
+
87
+ def _reference(
88
+ data: object,
89
+ path: str,
90
+ readers: PasswordReaders,
91
+ issues: _Issues,
92
+ stdin_uses: list[str],
93
+ ) -> tuple[bytes | None, str | None]:
94
+ """Resolve a password reference to ``(password, prompt_label)``."""
95
+ entry = _reference_entry(data, path, issues)
96
+ if entry is None:
97
+ return None, None
98
+ kind, value = entry
99
+ if kind == "stdin":
100
+ return _stdin_password(value, path, readers, issues, stdin_uses), None
101
+ if not isinstance(value, str) or not value:
102
+ issues.add(f"{path}.{kind}", "expected a non-empty string")
103
+ return None, None
104
+ if kind == "prompt":
105
+ return None, value
106
+ if kind == "env":
107
+ text = readers.env.get(value)
108
+ if not text:
109
+ issues.add(
110
+ f"{path}.env", f"environment variable {value} is not set or is empty"
111
+ )
112
+ return None, None
113
+ return password_bytes(text), None
114
+ try:
115
+ return readers.file(value), None
116
+ except CliError as exc:
117
+ issues.add(f"{path}.file", exc.message)
118
+ return None, None
119
+
120
+
121
+ def _match_pattern(fields: dict[str, object], path: str, issues: _Issues) -> str | None:
122
+ """The rule's ``match`` glob, or ``None`` if it is missing or invalid."""
123
+ given = fields.get("match")
124
+ if not isinstance(given, str) or not given:
125
+ issues.add(f"{path}.match", "required: a non-empty pattern string")
126
+ return None
127
+ try:
128
+ glob_matcher(given)
129
+ except CliError as exc:
130
+ issues.add(f"{path}.match", exc.message)
131
+ return None
132
+ return given
133
+
134
+
135
+ def _rule(
136
+ data: object,
137
+ path: str,
138
+ readers: PasswordReaders,
139
+ issues: _Issues,
140
+ stdin_uses: list[str],
141
+ *,
142
+ is_default: bool,
143
+ ) -> Rule | None:
144
+ fields = _as_object(data)
145
+ if fields is None:
146
+ issues.add(path, "expected an object")
147
+ return None
148
+ _check_keys(fields, _DEFAULT_KEYS if is_default else _RULE_KEYS, path, issues)
149
+ pattern: str | None = None
150
+ if not is_default:
151
+ pattern = _match_pattern(fields, path, issues)
152
+ if pattern is None:
153
+ return None
154
+ name = fields.get("method")
155
+ if not isinstance(name, str) or name not in ENCRYPTION_METHODS:
156
+ issues.add(f"{path}.method", f"required: one of {METHOD_CHOICES}")
157
+ return None
158
+ method = ENCRYPTION_METHODS[name]
159
+ if not method.is_encrypted:
160
+ if "password" in fields:
161
+ issues.add(f"{path}.password", 'not allowed with method "none"')
162
+ return Rule(pattern, method)
163
+ if "password" not in fields:
164
+ issues.add(f"{path}.password", "required for an encrypting method")
165
+ return None
166
+ password, prompt = _reference(
167
+ fields["password"], f"{path}.password", readers, issues, stdin_uses
168
+ )
169
+ if password is None and prompt is None:
170
+ return None
171
+ hint = None
172
+ if prompt is not None:
173
+ hint = (
174
+ f"the password for {printable(pattern) if pattern else 'the default rule'} "
175
+ 'can only be typed at a terminal; use an {"env": ...}, {"file": ...} or '
176
+ '{"stdin": true} reference in the spec to script it'
177
+ )
178
+ return Rule(pattern, method, password=password, prompt=prompt, hint=hint)
179
+
180
+
181
+ def _no_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]:
182
+ keys = [key for key, _ in pairs]
183
+ dupes = sorted({key for key in keys if keys.count(key) > 1})
184
+ if dupes:
185
+ raise ValueError(f"duplicate key {dupes[0]!r} in a JSON object")
186
+ return dict(pairs)
187
+
188
+
189
+ def _rule_items(data: dict[str, object], issues: _Issues) -> list[object]:
190
+ given = data.get("rules", [])
191
+ if isinstance(given, list):
192
+ return cast("list[object]", given)
193
+ issues.add("rules", "expected a list")
194
+ return []
195
+
196
+
197
+ def _list_rules(
198
+ items: list[object],
199
+ readers: PasswordReaders,
200
+ issues: _Issues,
201
+ stdin_uses: list[str],
202
+ ) -> list[Rule]:
203
+ rules: list[Rule] = []
204
+ seen: set[str] = set()
205
+ for index, item in enumerate(items):
206
+ rule = _rule(
207
+ item,
208
+ f"rules[{index}]",
209
+ readers,
210
+ issues,
211
+ stdin_uses,
212
+ is_default=False,
213
+ )
214
+ if rule is None:
215
+ continue
216
+ if rule.pattern in seen:
217
+ issues.add(f"rules[{index}].match", f"duplicate pattern {rule.pattern!r}")
218
+ seen.add(rule.pattern or "")
219
+ rules.append(rule)
220
+ return rules
221
+
222
+
223
+ def plan_from_spec(text: str, origin: str, readers: PasswordReaders) -> EncryptionPlan:
224
+ """Parse an encryption spec; every problem is reported together."""
225
+ where = f"invalid encryption spec ({printable(origin)})"
226
+ try:
227
+ # json.loads is typed to return Any
228
+ document: object = json.loads(text, object_pairs_hook=_no_duplicates) # pyright: ignore[reportAny]
229
+ except ValueError as exc:
230
+ raise UsageError(where, (printable(f"not valid JSON: {exc}"),)) from None
231
+ issues = _Issues()
232
+ data = _as_object(document)
233
+ if data is None:
234
+ raise UsageError(where, ("<spec>: expected a JSON object",))
235
+ _check_keys(data, _SPEC_KEYS, "", issues)
236
+ if data.get("version", 1) != 1:
237
+ issues.add("version", "unsupported; this zipctl reads version 1")
238
+ stdin_uses: list[str] = []
239
+ listed = _rule_items(data, issues)
240
+ rules = _list_rules(listed, readers, issues, stdin_uses)
241
+ if "default" in data:
242
+ default = _rule(
243
+ data["default"],
244
+ "default",
245
+ readers,
246
+ issues,
247
+ stdin_uses,
248
+ is_default=True,
249
+ )
250
+ if default is not None:
251
+ rules.append(default)
252
+ if not listed and "default" not in data and not issues.items:
253
+ issues.add("", "defines no rules and no default, so nothing would be encrypted")
254
+ if issues.items:
255
+ raise UsageError(where, tuple(printable(item) for item in issues.items))
256
+ return EncryptionPlan(rules)