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,104 @@
|
|
|
1
|
+
"""The ``policy`` command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command, add_subcommands
|
|
9
|
+
from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
|
|
10
|
+
from zipctl.cli.commands.helpers.policy_options import (
|
|
11
|
+
PolicyArgs,
|
|
12
|
+
add_policy_options,
|
|
13
|
+
load_policy,
|
|
14
|
+
)
|
|
15
|
+
from zipctl.cli.commands.helpers.sources import read_text_source
|
|
16
|
+
from zipctl.cli.context import Context
|
|
17
|
+
from zipctl.cli.errors import EXIT_OK, EXIT_USAGE, CliError, UsageError
|
|
18
|
+
from zipctl.cli.output import count, printable, write_json
|
|
19
|
+
from zipctl.zipfile.extract import (
|
|
20
|
+
ExtractPolicy,
|
|
21
|
+
)
|
|
22
|
+
from zipctl.zipfile.policy_config import (
|
|
23
|
+
PolicyConfigError,
|
|
24
|
+
policy_from_json,
|
|
25
|
+
policy_to_json,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ValidateArgs(OutputArgs, Protocol):
|
|
30
|
+
files: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _FileReport:
|
|
35
|
+
"""One policy file as ``policy validate --json`` reports it."""
|
|
36
|
+
|
|
37
|
+
file: str
|
|
38
|
+
valid: bool
|
|
39
|
+
issues: list[dict[str, str]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_policy_show(args: PolicyArgs, ctx: Context) -> int:
|
|
43
|
+
policy: ExtractPolicy = load_policy(args, ctx)
|
|
44
|
+
ctx.out(policy_to_json(policy))
|
|
45
|
+
return EXIT_OK
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_policy_validate(args: ValidateArgs, ctx: Context) -> int:
|
|
49
|
+
if args.files.count("-") > 1:
|
|
50
|
+
raise UsageError("standard input can only be given once")
|
|
51
|
+
reports: list[_FileReport] = []
|
|
52
|
+
for source in args.files:
|
|
53
|
+
issues: list[dict[str, str]] = []
|
|
54
|
+
try:
|
|
55
|
+
policy_from_json(read_text_source(source, ctx, "policy file"))
|
|
56
|
+
except PolicyConfigError as exc:
|
|
57
|
+
issues = [
|
|
58
|
+
{"path": issue.path, "message": issue.message} for issue in exc.issues
|
|
59
|
+
]
|
|
60
|
+
except CliError as exc: # unreadable file: report it and check the others
|
|
61
|
+
issues = [{"path": "", "message": exc.message}]
|
|
62
|
+
reports.append(_FileReport(source, not issues, issues))
|
|
63
|
+
|
|
64
|
+
invalid = [report for report in reports if not report.valid]
|
|
65
|
+
if args.json:
|
|
66
|
+
write_json(ctx.stdout, {"ok": not invalid, "files": reports})
|
|
67
|
+
else:
|
|
68
|
+
for report in reports:
|
|
69
|
+
name = printable(report.file)
|
|
70
|
+
if report.valid:
|
|
71
|
+
ctx.out(f"{'OK':<8}{name}")
|
|
72
|
+
continue
|
|
73
|
+
ctx.out(f"{'FAILED':<8}{name}: {count(len(report.issues), 'issue')}")
|
|
74
|
+
for issue in report.issues:
|
|
75
|
+
where = issue["path"] or "<policy>"
|
|
76
|
+
ctx.out(f" {printable(where)}: {printable(issue['message'])}")
|
|
77
|
+
ctx.out()
|
|
78
|
+
text = f"Checked {count(len(reports), 'policy file')}: "
|
|
79
|
+
ctx.out(text + (f"{len(invalid)} failed" if invalid else "all OK"))
|
|
80
|
+
return EXIT_USAGE if invalid else EXIT_OK
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def register(subparsers: Subparsers) -> None:
|
|
84
|
+
parser = add_command(
|
|
85
|
+
subparsers, "policy", None, "show or validate extraction policies"
|
|
86
|
+
)
|
|
87
|
+
actions = add_subcommands(parser, "policy_command")
|
|
88
|
+
show = add_command(
|
|
89
|
+
actions, "show", cmd_policy_show, "print the effective policy as JSON"
|
|
90
|
+
)
|
|
91
|
+
add_policy_options(show)
|
|
92
|
+
validate = add_command(
|
|
93
|
+
actions,
|
|
94
|
+
"validate",
|
|
95
|
+
cmd_policy_validate,
|
|
96
|
+
"check policy files without needing an archive",
|
|
97
|
+
)
|
|
98
|
+
validate.add_argument(
|
|
99
|
+
"files",
|
|
100
|
+
nargs="+",
|
|
101
|
+
metavar="FILE",
|
|
102
|
+
help="policy file ('-' reads standard input)",
|
|
103
|
+
)
|
|
104
|
+
add_output_options(validate)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The ``rewrite`` 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 (
|
|
17
|
+
PLAIN,
|
|
18
|
+
Target,
|
|
19
|
+
keep_target,
|
|
20
|
+
target_for_method,
|
|
21
|
+
)
|
|
22
|
+
from zipctl.cli.commands.helpers.encryption.options import (
|
|
23
|
+
EncryptionArgs,
|
|
24
|
+
EncryptionOptions,
|
|
25
|
+
add_encryption_options,
|
|
26
|
+
build_plan,
|
|
27
|
+
)
|
|
28
|
+
from zipctl.cli.commands.helpers.encryption.plan import warn_if_weak
|
|
29
|
+
from zipctl.cli.commands.helpers.passwords.options import (
|
|
30
|
+
OldPasswordArgs,
|
|
31
|
+
add_old_password_options,
|
|
32
|
+
add_password_options,
|
|
33
|
+
old_password_pool,
|
|
34
|
+
)
|
|
35
|
+
from zipctl.cli.context import Context
|
|
36
|
+
from zipctl.cli.errors import UsageError
|
|
37
|
+
from zipctl.cli.methods import COMPRESSION, require_compression, require_level
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RewriteArgs(CopyArgs, EncryptionArgs, OldPasswordArgs, Protocol):
|
|
41
|
+
compression: str | None
|
|
42
|
+
level: int | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_rewrite(args: RewriteArgs, ctx: Context) -> int:
|
|
46
|
+
job = copy_job(args)
|
|
47
|
+
check_paths(job)
|
|
48
|
+
if args.compression is not None:
|
|
49
|
+
require_compression(args.compression)
|
|
50
|
+
require_level(args.compression, args.level)
|
|
51
|
+
elif args.level is not None:
|
|
52
|
+
raise UsageError("--level needs --compression")
|
|
53
|
+
encryption = EncryptionOptions.from_args(args)
|
|
54
|
+
plan = build_plan(encryption, ctx)
|
|
55
|
+
with open_archive(job.input) as src:
|
|
56
|
+
infos = src.infolist()
|
|
57
|
+
assignment = plan.assign([info.filename for info in infos if not info.is_dir()])
|
|
58
|
+
if not job.report.quiet:
|
|
59
|
+
warn_if_weak(assignment.methods, ctx)
|
|
60
|
+
old_pool = old_password_pool(args, ctx)
|
|
61
|
+
passwords = read_passwords(src, infos, old_pool)
|
|
62
|
+
plan.resolve_prompts(assignment, ctx)
|
|
63
|
+
targets: list[Target] = []
|
|
64
|
+
for info, password in zip(infos, passwords, strict=True):
|
|
65
|
+
if info.is_dir():
|
|
66
|
+
targets.append(PLAIN)
|
|
67
|
+
elif rule := assignment.chosen.get(info.filename):
|
|
68
|
+
targets.append(
|
|
69
|
+
target_for_method(
|
|
70
|
+
rule.method, rule.password, encryption.wz_aes_version
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
targets.append(keep_target(info, password))
|
|
75
|
+
return run_copy(
|
|
76
|
+
ctx,
|
|
77
|
+
src,
|
|
78
|
+
passwords,
|
|
79
|
+
targets,
|
|
80
|
+
job,
|
|
81
|
+
verb="Rewrote",
|
|
82
|
+
compress_type=None
|
|
83
|
+
if args.compression is None
|
|
84
|
+
else COMPRESSION[args.compression],
|
|
85
|
+
level=args.level,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register(subparsers: Subparsers) -> None:
|
|
90
|
+
parser = add_command(
|
|
91
|
+
subparsers,
|
|
92
|
+
"rewrite",
|
|
93
|
+
cmd_rewrite,
|
|
94
|
+
"copy an archive into a new one, changing its encryption or compression",
|
|
95
|
+
)
|
|
96
|
+
add_copy_options(parser)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--compression",
|
|
99
|
+
metavar="METHOD",
|
|
100
|
+
choices=list(COMPRESSION),
|
|
101
|
+
help=f"Compression method for every member: {', '.join(COMPRESSION)}. "
|
|
102
|
+
"Default: each member keeps its own",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"-L",
|
|
106
|
+
"--level",
|
|
107
|
+
type=int,
|
|
108
|
+
metavar="N",
|
|
109
|
+
help="Compression level. Needs --compression",
|
|
110
|
+
)
|
|
111
|
+
add_encryption_options(parser)
|
|
112
|
+
add_old_password_options(parser)
|
|
113
|
+
add_password_options(parser)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The ``test`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from zipctl.cli.archive import CHUNK, open_archive
|
|
9
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command
|
|
10
|
+
from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
|
|
11
|
+
from zipctl.cli.commands.helpers.passwords.options import (
|
|
12
|
+
PasswordArgs,
|
|
13
|
+
PasswordOptions,
|
|
14
|
+
add_password_options,
|
|
15
|
+
password_pool,
|
|
16
|
+
)
|
|
17
|
+
from zipctl.cli.commands.helpers.passwords.pool import PasswordPool, PasswordProblem
|
|
18
|
+
from zipctl.cli.commands.helpers.progress import (
|
|
19
|
+
ProgressArgs,
|
|
20
|
+
ProgressRenderer,
|
|
21
|
+
StepReporter,
|
|
22
|
+
add_progress_option,
|
|
23
|
+
progress_renderer,
|
|
24
|
+
)
|
|
25
|
+
from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
|
|
26
|
+
from zipctl.cli.context import Context
|
|
27
|
+
from zipctl.cli.errors import EXIT_FAILURE, EXIT_OK
|
|
28
|
+
from zipctl.cli.output import (
|
|
29
|
+
count,
|
|
30
|
+
printable,
|
|
31
|
+
write_json,
|
|
32
|
+
)
|
|
33
|
+
from zipctl.exceptions import BadZipFile
|
|
34
|
+
from zipctl.zipfile.file import ZipFile
|
|
35
|
+
from zipctl.zipfile.info import ZipInfo
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestArgs(OutputArgs, PasswordArgs, ProgressArgs, Protocol):
|
|
39
|
+
archive: str
|
|
40
|
+
members: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Tested:
|
|
45
|
+
"""The outcome for one member; *detail* says what is wrong when it is not *ok*."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
ok: bool
|
|
49
|
+
detail: str | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def status(self) -> str:
|
|
53
|
+
return "ok" if self.ok else "failed"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _test_member(zf: ZipFile, info: ZipInfo, pool: PasswordPool) -> Tested:
|
|
57
|
+
"""Read *info* to its end."""
|
|
58
|
+
password = None
|
|
59
|
+
if info.is_encrypted:
|
|
60
|
+
resolved = pool.resolve(zf, info)
|
|
61
|
+
if isinstance(resolved, PasswordProblem):
|
|
62
|
+
return Tested(info.filename, False, resolved.text)
|
|
63
|
+
password = resolved
|
|
64
|
+
try:
|
|
65
|
+
with zf.open(info, pwd=password) as stream:
|
|
66
|
+
while stream.read(CHUNK):
|
|
67
|
+
pass
|
|
68
|
+
except Exception as exc: # any failure to read a member means it is bad
|
|
69
|
+
return Tested(info.filename, False, _read_error_text(exc, info))
|
|
70
|
+
return Tested(info.filename, True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_error_text(exc: Exception, info: ZipInfo) -> str:
|
|
74
|
+
text = str(exc) or type(exc).__name__
|
|
75
|
+
if isinstance(exc, BadZipFile) and info.is_encrypted:
|
|
76
|
+
return f"{text} (corrupt data, or the password is wrong)"
|
|
77
|
+
if isinstance(exc, EOFError):
|
|
78
|
+
return "data is truncated"
|
|
79
|
+
if isinstance(exc, (NotImplementedError, RuntimeError)):
|
|
80
|
+
return f"unsupported: {text}"
|
|
81
|
+
if isinstance(exc, BadZipFile):
|
|
82
|
+
return text
|
|
83
|
+
return f"{type(exc).__name__}: {text}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _test_members(
|
|
87
|
+
zf: ZipFile,
|
|
88
|
+
infos: list[ZipInfo],
|
|
89
|
+
pool: PasswordPool,
|
|
90
|
+
renderer: ProgressRenderer | None,
|
|
91
|
+
) -> list[Tested]:
|
|
92
|
+
steps = StepReporter(renderer, ((i.filename, i.file_size) for i in infos))
|
|
93
|
+
results: list[Tested] = []
|
|
94
|
+
for index, info in enumerate(infos):
|
|
95
|
+
steps.start(index)
|
|
96
|
+
if renderer is not None and info.is_encrypted:
|
|
97
|
+
renderer.clear() # keep a prompt from colliding with the status line
|
|
98
|
+
result = _test_member(zf, info, pool)
|
|
99
|
+
results.append(result)
|
|
100
|
+
steps.finish(index, ok=result.ok)
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_test(args: TestArgs, ctx: Context) -> int:
|
|
105
|
+
with progress_renderer(ctx.stderr, args.progress) as renderer:
|
|
106
|
+
with open_archive(args.archive) as zf:
|
|
107
|
+
pool = password_pool(PasswordOptions.from_args(args), ctx)
|
|
108
|
+
infos = select_infos(zf.infolist(), args.members)
|
|
109
|
+
results = _test_members(zf, infos, pool, renderer)
|
|
110
|
+
|
|
111
|
+
failed = [result for result in results if not result.ok]
|
|
112
|
+
if args.json:
|
|
113
|
+
write_json(
|
|
114
|
+
ctx.stdout,
|
|
115
|
+
{
|
|
116
|
+
"ok": not failed,
|
|
117
|
+
"archive": args.archive,
|
|
118
|
+
"tested": len(results),
|
|
119
|
+
"failed": len(failed),
|
|
120
|
+
"members": [
|
|
121
|
+
{"name": r.name, "status": r.status, "detail": r.detail}
|
|
122
|
+
for r in results
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
lines: list[str] = []
|
|
128
|
+
for result in results:
|
|
129
|
+
if not result.ok:
|
|
130
|
+
name, detail = printable(result.name), printable(result.detail or "")
|
|
131
|
+
lines.append(f"FAILED {name}: {detail}")
|
|
132
|
+
elif args.verbose:
|
|
133
|
+
lines.append(f"OK {printable(result.name)}")
|
|
134
|
+
for line in lines:
|
|
135
|
+
ctx.out(line)
|
|
136
|
+
if failed or not args.quiet:
|
|
137
|
+
if lines:
|
|
138
|
+
ctx.out("")
|
|
139
|
+
tested = f"Tested {count(len(results), 'member')}"
|
|
140
|
+
ctx.out(
|
|
141
|
+
f"{tested}: {len(failed)} failed" if failed else f"{tested}: all OK"
|
|
142
|
+
)
|
|
143
|
+
return EXIT_FAILURE if failed else EXIT_OK
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register(subparsers: Subparsers) -> None:
|
|
147
|
+
parser = add_command(
|
|
148
|
+
subparsers, "test", cmd_test, "read every member and check its integrity"
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument("archive", metavar="ARCHIVE", help="the archive to test")
|
|
151
|
+
parser.add_argument("members", nargs="*", metavar="MEMBER", help=MEMBER_HELP)
|
|
152
|
+
add_progress_option(parser)
|
|
153
|
+
add_output_options(parser, verbose_help="also list members that pass", quiet=True)
|
|
154
|
+
add_password_options(parser)
|
zipctl/cli/context.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The streams and environment a command runs against."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import BinaryIO, TextIO
|
|
10
|
+
|
|
11
|
+
from zipctl.cli.errors import UsageError
|
|
12
|
+
|
|
13
|
+
__all__ = ["Context"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Context:
|
|
18
|
+
"""The streams and environment a command runs against."""
|
|
19
|
+
|
|
20
|
+
stdin: TextIO
|
|
21
|
+
stdout: TextIO
|
|
22
|
+
stderr: TextIO
|
|
23
|
+
environ: Mapping[str, str]
|
|
24
|
+
_stdin_user: str | None = field(default=None, init=False, repr=False)
|
|
25
|
+
warned: bool = field(default=False, init=False, repr=False)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_process(cls) -> Context:
|
|
29
|
+
"""Bind to the current ``sys`` streams (looked up at call time)."""
|
|
30
|
+
return cls(sys.stdin, sys.stdout, sys.stderr, os.environ)
|
|
31
|
+
|
|
32
|
+
def claim_stdin(self, purpose: str) -> None:
|
|
33
|
+
"""Reserve standard input for *purpose*; it can only be read once."""
|
|
34
|
+
if self._stdin_user is not None:
|
|
35
|
+
raise UsageError(
|
|
36
|
+
f"standard input cannot be used for both {self._stdin_user} "
|
|
37
|
+
f"and {purpose}",
|
|
38
|
+
)
|
|
39
|
+
self._stdin_user = purpose
|
|
40
|
+
|
|
41
|
+
def read_stdin(self, *, line: bool = False) -> bytes:
|
|
42
|
+
"""The rest of standard input, or only its next line, as bytes."""
|
|
43
|
+
binary: BinaryIO | None = getattr(self.stdin, "buffer", None)
|
|
44
|
+
if binary is not None:
|
|
45
|
+
return binary.readline() if line else binary.read()
|
|
46
|
+
text = self.stdin.readline() if line else self.stdin.read()
|
|
47
|
+
return text.encode("utf-8")
|
|
48
|
+
|
|
49
|
+
def out(self, line: str = "") -> None:
|
|
50
|
+
print(line, file=self.stdout)
|
|
51
|
+
|
|
52
|
+
def err(self, line: str = "") -> None:
|
|
53
|
+
print(line, file=self.stderr)
|
|
54
|
+
|
|
55
|
+
def warn(self, text: str) -> None:
|
|
56
|
+
self.warned = True
|
|
57
|
+
self.err(f"zipctl: warning: {text}")
|
zipctl/cli/errors.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exit codes and the error type command handlers raise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zipctl.cli.output import format_table
|
|
6
|
+
|
|
7
|
+
EXIT_OK = 0
|
|
8
|
+
EXIT_FAILURE = 1 # the operation ran and failed (bad member, violations, ...)
|
|
9
|
+
EXIT_USAGE = 2 # bad command line or bad configuration file
|
|
10
|
+
EXIT_BROKEN_PIPE = 141 # 128 + SIGPIPE: the reader of our output went away
|
|
11
|
+
EXIT_INTERRUPTED = 130 # 128 + SIGINT
|
|
12
|
+
|
|
13
|
+
EXIT_CODES = (
|
|
14
|
+
(EXIT_OK, "success"),
|
|
15
|
+
(EXIT_FAILURE, "the operation ran and failed (bad member, policy violations, ...)"),
|
|
16
|
+
(
|
|
17
|
+
EXIT_USAGE,
|
|
18
|
+
"bad command line, bad configuration file or a pattern matching nothing",
|
|
19
|
+
),
|
|
20
|
+
(EXIT_INTERRUPTED, "interrupted"),
|
|
21
|
+
(EXIT_BROKEN_PIPE, "the reader of the output went away"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def exit_codes_table() -> str:
|
|
26
|
+
"""The exit codes and their meaning, as printed by ``zipctl --exit-codes``."""
|
|
27
|
+
rows = [[str(code), meaning] for code, meaning in EXIT_CODES]
|
|
28
|
+
return "\n".join(format_table(["CODE", "MEANING"], rows))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CliError(Exception):
|
|
32
|
+
"""A failure to report as ``zipctl: error: MESSAGE`` and exit with *code*."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, message: str, code: int = EXIT_FAILURE, details: tuple[str, ...] = ()
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message: str = message
|
|
39
|
+
self.code: int = code
|
|
40
|
+
self.details: tuple[str, ...] = details
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UsageError(CliError):
|
|
44
|
+
"""A bad command line or configuration: exit code :data:`EXIT_USAGE`."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, details: tuple[str, ...] = ()) -> None:
|
|
47
|
+
super().__init__(message, EXIT_USAGE, details)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def os_error_text(exc: OSError) -> str:
|
|
51
|
+
"""The system's wording for *exc* (its message when it has no error code)."""
|
|
52
|
+
return exc.strerror or str(exc)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def os_error_filename(exc: OSError) -> str | None:
|
|
56
|
+
"""The path *exc* is about, or ``None`` when it names none."""
|
|
57
|
+
name: object = exc.filename # pyright: ignore[reportAny] # typeshed: Any
|
|
58
|
+
return str(name) if name else None
|
zipctl/cli/formatter.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The help formatter every ``zipctl`` parser uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
__all__ = ["HelpFormatter"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _capitalize(text: str) -> str:
|
|
14
|
+
"""Upper-case the first letter only (``str.capitalize`` would lower the rest)."""
|
|
15
|
+
return text[:1].upper() + text[1:]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _option_name(action: argparse.Action) -> str:
|
|
19
|
+
"""The name options are sorted by: the long option, without its dashes."""
|
|
20
|
+
strings = action.option_strings
|
|
21
|
+
return next((s for s in strings if s.startswith("--")), strings[0]).lstrip("-")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
25
|
+
"""Capitalise headings and descriptions; list the options alphabetically."""
|
|
26
|
+
|
|
27
|
+
_options_section: bool = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, prog: str) -> None:
|
|
30
|
+
# a wider help column leaves room for "-d, --destination DIR"
|
|
31
|
+
super().__init__(prog, max_help_position=30)
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def start_section(self, heading: str | None) -> None:
|
|
35
|
+
self._options_section = heading == "options"
|
|
36
|
+
if heading == "positional arguments":
|
|
37
|
+
heading = "arguments"
|
|
38
|
+
super().start_section(_capitalize(heading) if heading else heading)
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def add_arguments(self, actions: Iterable[argparse.Action]) -> None:
|
|
42
|
+
if self._options_section:
|
|
43
|
+
actions = sorted(actions, key=lambda action: _option_name(action).lower())
|
|
44
|
+
super().add_arguments(actions)
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def _format_action(self, action: argparse.Action) -> str:
|
|
48
|
+
"""List subcommands directly, without argparse's ``COMMAND`` header line."""
|
|
49
|
+
if isinstance(action, argparse._SubParsersAction): # pyright: ignore[reportPrivateUsage]
|
|
50
|
+
return "".join(
|
|
51
|
+
super(HelpFormatter, self)._format_action(sub)
|
|
52
|
+
for sub in action._get_subactions()
|
|
53
|
+
)
|
|
54
|
+
return super()._format_action(action)
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def _format_text(self, text: str) -> str:
|
|
58
|
+
return super()._format_text(_capitalize(text))
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
def _expand_help(self, action: argparse.Action) -> str:
|
|
62
|
+
return _capitalize(super()._expand_help(action))
|
zipctl/cli/methods.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The compression and encryption methods the CLI knows, in one place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.errors import UsageError
|
|
8
|
+
from zipctl.compression import (
|
|
9
|
+
ZIP_BZIP2,
|
|
10
|
+
ZIP_DEFLATED,
|
|
11
|
+
ZIP_LZMA,
|
|
12
|
+
ZIP_STORED,
|
|
13
|
+
ZIP_ZSTANDARD,
|
|
14
|
+
compressor_names,
|
|
15
|
+
registry,
|
|
16
|
+
)
|
|
17
|
+
from zipctl.cryptography import WZ_AES, ZIP_CRYPTO
|
|
18
|
+
from zipctl.zipfile.file import ZipFileExtra
|
|
19
|
+
from zipctl.zipfile.info import ZipInfo
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"COMPRESSION",
|
|
23
|
+
"ENCRYPTABLE",
|
|
24
|
+
"ENCRYPTION_METHODS",
|
|
25
|
+
"EncryptionMethod",
|
|
26
|
+
"METHOD_CHOICES",
|
|
27
|
+
"NO_ENCRYPTION",
|
|
28
|
+
"UNKNOWN_AES",
|
|
29
|
+
"compression_label",
|
|
30
|
+
"extra_for",
|
|
31
|
+
"method_of",
|
|
32
|
+
"require_compression",
|
|
33
|
+
"require_level",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class EncryptionMethod:
|
|
39
|
+
"""One name a user can give for how a member is protected."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
scheme: str | None # the ZipFile encryption scheme; None for plain
|
|
43
|
+
aes_bits: int # AES key size; 0 when the scheme is not AES
|
|
44
|
+
label: str # how listings and reports call it
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_aes(self) -> bool:
|
|
48
|
+
return self.scheme == WZ_AES
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_encrypted(self) -> bool:
|
|
52
|
+
return self.scheme is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ENCRYPTION_METHODS = {
|
|
56
|
+
method.name: method
|
|
57
|
+
for method in (
|
|
58
|
+
EncryptionMethod("aes256", WZ_AES, 256, "AES-256"),
|
|
59
|
+
EncryptionMethod("aes192", WZ_AES, 192, "AES-192"),
|
|
60
|
+
EncryptionMethod("aes128", WZ_AES, 128, "AES-128"),
|
|
61
|
+
EncryptionMethod("zipcrypto", ZIP_CRYPTO, 0, "ZipCrypto"),
|
|
62
|
+
EncryptionMethod("none", None, 0, "none"),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
METHOD_CHOICES = ", ".join(ENCRYPTION_METHODS) # for messages and help
|
|
66
|
+
ENCRYPTABLE = tuple(name for name in ENCRYPTION_METHODS if name != "none")
|
|
67
|
+
NO_ENCRYPTION = ENCRYPTION_METHODS["none"]
|
|
68
|
+
# An AES strength this tool does not know; readable in a listing, never written.
|
|
69
|
+
UNKNOWN_AES = EncryptionMethod("aes", WZ_AES, 0, "AES")
|
|
70
|
+
|
|
71
|
+
# Method names the CLI takes -> ZIP compression ids.
|
|
72
|
+
COMPRESSION = {
|
|
73
|
+
compressor_names[compress_type]: compress_type
|
|
74
|
+
for compress_type in (
|
|
75
|
+
ZIP_STORED,
|
|
76
|
+
ZIP_DEFLATED,
|
|
77
|
+
ZIP_BZIP2,
|
|
78
|
+
ZIP_LZMA,
|
|
79
|
+
ZIP_ZSTANDARD,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Compression levels each method accepts (the registry does not record them);
|
|
84
|
+
# store and lzma take none.
|
|
85
|
+
_LEVELS = {
|
|
86
|
+
"deflate": range(10),
|
|
87
|
+
"bzip2": range(1, 10),
|
|
88
|
+
"zstd": range(-131072, 23),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def method_of(info: ZipInfo) -> EncryptionMethod:
|
|
93
|
+
"""How the member described by *info* is protected in its archive."""
|
|
94
|
+
scheme = info.encryption_scheme
|
|
95
|
+
if scheme is None:
|
|
96
|
+
return NO_ENCRYPTION
|
|
97
|
+
if scheme == ZIP_CRYPTO:
|
|
98
|
+
return ENCRYPTION_METHODS["zipcrypto"]
|
|
99
|
+
bits = info.aes_bits
|
|
100
|
+
return ENCRYPTION_METHODS[f"aes{bits}"] if bits else UNKNOWN_AES
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extra_for(method: EncryptionMethod, aes_version: int | None) -> ZipFileExtra | None:
|
|
104
|
+
"""The per-entry AES settings for *method* (``None`` when it has none)."""
|
|
105
|
+
if not method.is_aes:
|
|
106
|
+
return None
|
|
107
|
+
return ZipFileExtra(force_wz_aes_version=aes_version, wz_aes_nbits=method.aes_bits)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_compression(method: str) -> None:
|
|
111
|
+
"""Refuse a *method* (a key of :data:`COMPRESSION`) this install cannot run."""
|
|
112
|
+
try:
|
|
113
|
+
registry.check_compression(COMPRESSION[method])
|
|
114
|
+
except (RuntimeError, NotImplementedError):
|
|
115
|
+
extra = " (install zipctl[zstd])" if method == "zstd" else ""
|
|
116
|
+
raise UsageError(
|
|
117
|
+
f"compression method {method!r} is not available{extra}"
|
|
118
|
+
) from None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def require_level(method: str, level: int | None) -> None:
|
|
122
|
+
"""Refuse a *level* the compression *method* cannot use."""
|
|
123
|
+
if level is None:
|
|
124
|
+
return
|
|
125
|
+
levels = _LEVELS.get(method)
|
|
126
|
+
if levels is None:
|
|
127
|
+
raise UsageError(f"compression method {method!r} takes no --level")
|
|
128
|
+
if level not in levels:
|
|
129
|
+
raise UsageError(
|
|
130
|
+
f"--level {level} is out of range for {method} "
|
|
131
|
+
f"({levels[0]} to {levels[-1]})",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def compression_label(info: ZipInfo) -> str:
|
|
136
|
+
return compressor_names.get(info.compress_type, str(info.compress_type))
|