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,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)
|