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
zipctl/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from zipctl.compression import (
|
|
2
|
+
ZIP_BZIP2,
|
|
3
|
+
ZIP_DEFLATED,
|
|
4
|
+
ZIP_LZMA,
|
|
5
|
+
ZIP_STORED,
|
|
6
|
+
ZIP_ZSTANDARD,
|
|
7
|
+
Registry,
|
|
8
|
+
)
|
|
9
|
+
from zipctl.cryptography import WZ_AES, WZ_AES_V1, WZ_AES_V2, ZIP_CRYPTO
|
|
10
|
+
from zipctl.exceptions import BadPassword, PasswordError, PasswordRequired
|
|
11
|
+
from zipctl.zipfile.assessment import ArchiveAssessment, ExtractionContext
|
|
12
|
+
from zipctl.zipfile.exceptions import (
|
|
13
|
+
ExtractionFailure,
|
|
14
|
+
ExtractionMaterializationError,
|
|
15
|
+
ExtractionQuotaExceeded,
|
|
16
|
+
ExtractionSecurityError,
|
|
17
|
+
)
|
|
18
|
+
from zipctl.zipfile.extract import (
|
|
19
|
+
ExtractionError,
|
|
20
|
+
ExtractMemberResult,
|
|
21
|
+
ExtractPolicy,
|
|
22
|
+
ExtractPolicyRule,
|
|
23
|
+
ExtractResult,
|
|
24
|
+
ExtractViolation,
|
|
25
|
+
MemberAssessment,
|
|
26
|
+
MemberStatus,
|
|
27
|
+
OverwritePolicy,
|
|
28
|
+
ViolationAction,
|
|
29
|
+
)
|
|
30
|
+
from zipctl.zipfile.file import (
|
|
31
|
+
INHERIT_ENCRYPTION,
|
|
32
|
+
EncryptionOverride,
|
|
33
|
+
PasswordProvider,
|
|
34
|
+
ZipFile,
|
|
35
|
+
ZipFileExtra,
|
|
36
|
+
is_zipfile,
|
|
37
|
+
)
|
|
38
|
+
from zipctl.zipfile.info import WzAesExtra
|
|
39
|
+
from zipctl.zipfile.inspection import InspectionMember, InspectionResult
|
|
40
|
+
from zipctl.zipfile.password import (
|
|
41
|
+
MemberPasswordCheck,
|
|
42
|
+
PasswordCheckResult,
|
|
43
|
+
PasswordStatus,
|
|
44
|
+
)
|
|
45
|
+
from zipctl.zipfile.path import Path
|
|
46
|
+
from zipctl.zipfile.policy_config import (
|
|
47
|
+
PolicyConfigError,
|
|
48
|
+
PolicyIssue,
|
|
49
|
+
policy_from_json,
|
|
50
|
+
policy_from_mapping,
|
|
51
|
+
policy_to_json,
|
|
52
|
+
policy_to_mapping,
|
|
53
|
+
)
|
|
54
|
+
from zipctl.zipfile.progress import ProgressCallback, ProgressEvent, ProgressPhase
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"WZ_AES",
|
|
58
|
+
"WZ_AES_V1",
|
|
59
|
+
"WZ_AES_V2",
|
|
60
|
+
"WzAesExtra",
|
|
61
|
+
"ZipFileExtra",
|
|
62
|
+
"ZIP_CRYPTO",
|
|
63
|
+
"ZIP_STORED",
|
|
64
|
+
"ZIP_DEFLATED",
|
|
65
|
+
"ZIP_BZIP2",
|
|
66
|
+
"ZIP_LZMA",
|
|
67
|
+
"ZIP_ZSTANDARD",
|
|
68
|
+
"Registry",
|
|
69
|
+
"ZipFile",
|
|
70
|
+
"is_zipfile",
|
|
71
|
+
"INHERIT_ENCRYPTION",
|
|
72
|
+
"EncryptionOverride",
|
|
73
|
+
"ExtractMemberResult",
|
|
74
|
+
"ExtractPolicy",
|
|
75
|
+
"ExtractPolicyRule",
|
|
76
|
+
"ExtractResult",
|
|
77
|
+
"ExtractViolation",
|
|
78
|
+
"ExtractionError",
|
|
79
|
+
"ExtractionFailure",
|
|
80
|
+
"ExtractionMaterializationError",
|
|
81
|
+
"ExtractionQuotaExceeded",
|
|
82
|
+
"ExtractionSecurityError",
|
|
83
|
+
"MemberStatus",
|
|
84
|
+
"MemberAssessment",
|
|
85
|
+
"ArchiveAssessment",
|
|
86
|
+
"BadPassword",
|
|
87
|
+
"MemberPasswordCheck",
|
|
88
|
+
"PasswordCheckResult",
|
|
89
|
+
"PasswordError",
|
|
90
|
+
"PasswordProvider",
|
|
91
|
+
"PasswordRequired",
|
|
92
|
+
"PasswordStatus",
|
|
93
|
+
"PolicyConfigError",
|
|
94
|
+
"PolicyIssue",
|
|
95
|
+
"ProgressCallback",
|
|
96
|
+
"ProgressEvent",
|
|
97
|
+
"ProgressPhase",
|
|
98
|
+
"policy_from_json",
|
|
99
|
+
"policy_from_mapping",
|
|
100
|
+
"policy_to_json",
|
|
101
|
+
"policy_to_mapping",
|
|
102
|
+
"ExtractionContext",
|
|
103
|
+
"OverwritePolicy",
|
|
104
|
+
"ViolationAction",
|
|
105
|
+
"InspectionMember",
|
|
106
|
+
"InspectionResult",
|
|
107
|
+
"Path",
|
|
108
|
+
]
|
zipctl/__main__.py
ADDED
zipctl/cli/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line interface: ``zipctl`` and ``python -m zipctl``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from zipctl.cli.commands.helpers.command import Handler
|
|
12
|
+
from zipctl.cli.context import Context
|
|
13
|
+
from zipctl.cli.errors import (
|
|
14
|
+
EXIT_BROKEN_PIPE,
|
|
15
|
+
EXIT_FAILURE,
|
|
16
|
+
EXIT_INTERRUPTED,
|
|
17
|
+
EXIT_OK,
|
|
18
|
+
CliError,
|
|
19
|
+
os_error_filename,
|
|
20
|
+
os_error_text,
|
|
21
|
+
)
|
|
22
|
+
from zipctl.cli.output import printable, write_json
|
|
23
|
+
from zipctl.cli.parser import build_parser
|
|
24
|
+
|
|
25
|
+
__all__ = ["main"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Parsed(argparse.Namespace):
|
|
29
|
+
"""The parsed arguments, with the handler the chosen command registered."""
|
|
30
|
+
|
|
31
|
+
handler: Handler # pyright: ignore[reportUninitializedInstanceVariable] # set by argparse
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _exit_code(code: object) -> int:
|
|
35
|
+
if code is None:
|
|
36
|
+
return EXIT_OK
|
|
37
|
+
return code if isinstance(code, int) else 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quiet_broken_pipe() -> None:
|
|
41
|
+
"""Stop Python complaining again about the closed pipe when it exits."""
|
|
42
|
+
try:
|
|
43
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
44
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fail(
|
|
50
|
+
ctx: Context,
|
|
51
|
+
args: argparse.Namespace,
|
|
52
|
+
exc: Exception,
|
|
53
|
+
message: str,
|
|
54
|
+
code: int,
|
|
55
|
+
details: tuple[str, ...] = (),
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Report *message* on standard error (and as JSON with ``--json``)."""
|
|
58
|
+
if ctx.environ.get("ZIPCTL_DEBUG"):
|
|
59
|
+
exc.__suppress_context__ = False # show the error a ``from None`` hid
|
|
60
|
+
traceback.print_exception(exc, file=ctx.stderr)
|
|
61
|
+
ctx.err(f"zipctl: error: {message}")
|
|
62
|
+
for line in details:
|
|
63
|
+
ctx.err(f" {line}")
|
|
64
|
+
wants_json: bool = getattr(args, "json", False) # not every command has --json
|
|
65
|
+
if wants_json:
|
|
66
|
+
error = {"ok": False, "error": message, "code": code, "details": list(details)}
|
|
67
|
+
write_json(ctx.stdout, error)
|
|
68
|
+
return code
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
72
|
+
"""Run the CLI with *argv* (default: ``sys.argv[1:]``); return the exit code."""
|
|
73
|
+
ctx = Context.from_process()
|
|
74
|
+
for stream in (ctx.stdout, ctx.stderr):
|
|
75
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
76
|
+
if reconfigure is not None:
|
|
77
|
+
reconfigure(errors="backslashreplace")
|
|
78
|
+
|
|
79
|
+
parser = build_parser()
|
|
80
|
+
try:
|
|
81
|
+
args = parser.parse_args(
|
|
82
|
+
sys.argv[1:] if argv is None else argv, namespace=_Parsed()
|
|
83
|
+
)
|
|
84
|
+
except SystemExit as exc:
|
|
85
|
+
return _exit_code(exc.code)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
code = args.handler(args, ctx)
|
|
89
|
+
ctx.stdout.flush()
|
|
90
|
+
return int(code)
|
|
91
|
+
except CliError as exc:
|
|
92
|
+
return _fail(ctx, args, exc, exc.message, exc.code, exc.details)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
ctx.err("zipctl: interrupted")
|
|
95
|
+
return EXIT_INTERRUPTED
|
|
96
|
+
except BrokenPipeError:
|
|
97
|
+
_quiet_broken_pipe()
|
|
98
|
+
return EXIT_BROKEN_PIPE
|
|
99
|
+
except OSError as exc: # whatever a command did not translate itself
|
|
100
|
+
filename = os_error_filename(exc)
|
|
101
|
+
where = f"{printable(filename)}: " if filename else ""
|
|
102
|
+
message = f"{where}{printable(os_error_text(exc))}"
|
|
103
|
+
return _fail(ctx, args, exc, message, EXIT_FAILURE)
|
|
104
|
+
except Exception as exc: # a bug: name it, and offer the traceback
|
|
105
|
+
message = f"unexpected {type(exc).__name__}: {printable(str(exc))}"
|
|
106
|
+
if not ctx.environ.get("ZIPCTL_DEBUG"):
|
|
107
|
+
message += " (set ZIPCTL_DEBUG=1 for the traceback)"
|
|
108
|
+
return _fail(ctx, args, exc, message, EXIT_FAILURE)
|
zipctl/cli/archive.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Opening archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zipctl.cli.errors import CliError, os_error_text
|
|
6
|
+
from zipctl.cli.output import printable
|
|
7
|
+
from zipctl.exceptions import BadZipFile
|
|
8
|
+
from zipctl.zipfile.file import ZipFile
|
|
9
|
+
|
|
10
|
+
__all__ = ["CHUNK", "open_archive"]
|
|
11
|
+
|
|
12
|
+
CHUNK = 1 << 20 # bytes read at a time when streaming a member
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def open_archive(path: str) -> ZipFile:
|
|
16
|
+
try:
|
|
17
|
+
return ZipFile(path)
|
|
18
|
+
except BadZipFile:
|
|
19
|
+
raise CliError(f"{printable(path)}: not a valid ZIP archive") from None
|
|
20
|
+
except NotImplementedError as exc:
|
|
21
|
+
raise CliError(f"{printable(path)}: unsupported ZIP feature ({exc})") from None
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
raise CliError(f"cannot open {printable(path)}: {os_error_text(exc)}") from None
|
zipctl/cli/atomic.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Writing an archive so that a failed run leaves nothing behind."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
|
|
11
|
+
from zipctl.cli.errors import CliError, os_error_text
|
|
12
|
+
from zipctl.cli.output import printable
|
|
13
|
+
|
|
14
|
+
__all__ = ["replacing"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sync_directory(directory: str) -> None:
|
|
18
|
+
"""Make the rename durable; best effort (not every platform can do it)."""
|
|
19
|
+
try:
|
|
20
|
+
handle = os.open(directory, os.O_RDONLY)
|
|
21
|
+
try:
|
|
22
|
+
os.fsync(handle)
|
|
23
|
+
finally:
|
|
24
|
+
os.close(handle)
|
|
25
|
+
except OSError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def replacing(path: str, *, overwrite: bool, seed: bool = False) -> Generator[str]:
|
|
31
|
+
"""Yield a scratch path beside *path*; move it onto *path* if the block succeeds.
|
|
32
|
+
|
|
33
|
+
The scratch file is removed if the block raises (Ctrl-C included), so *path*
|
|
34
|
+
is either untouched or complete. An existing *path* keeps its permission
|
|
35
|
+
bits. With *seed*, the scratch file starts as a copy of it (for
|
|
36
|
+
appending). Unless *overwrite*, an
|
|
37
|
+
existing *path* is refused, both up front and again just before the move.
|
|
38
|
+
"""
|
|
39
|
+
target = os.path.realpath(path) # through a symlink, so the link survives
|
|
40
|
+
exists = os.path.lexists(target)
|
|
41
|
+
if exists and not overwrite:
|
|
42
|
+
raise CliError(f"{printable(path)} already exists (use --force to replace it)")
|
|
43
|
+
directory = os.path.dirname(target)
|
|
44
|
+
scratch = os.path.join(directory, f".zipctl-{secrets.token_hex(8)}.tmp")
|
|
45
|
+
try:
|
|
46
|
+
# 0o666 here: the kernel applies the umask, without touching ours
|
|
47
|
+
handle = os.open(scratch, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise CliError(
|
|
50
|
+
f"cannot create {printable(path)}: {os_error_text(exc)}"
|
|
51
|
+
) from None
|
|
52
|
+
os.close(handle)
|
|
53
|
+
try:
|
|
54
|
+
if seed and exists:
|
|
55
|
+
shutil.copyfile(target, scratch)
|
|
56
|
+
if exists:
|
|
57
|
+
shutil.copymode(target, scratch) # a 0600 archive stays 0600
|
|
58
|
+
yield scratch
|
|
59
|
+
with open(scratch, "rb+") as written:
|
|
60
|
+
os.fsync(written.fileno())
|
|
61
|
+
# ponytail: check-then-replace, so a file created in this instant is
|
|
62
|
+
# replaced; os.link would be exclusive but is not portable.
|
|
63
|
+
if not overwrite and os.path.lexists(target):
|
|
64
|
+
raise CliError(f"{printable(path)} already exists")
|
|
65
|
+
try:
|
|
66
|
+
os.replace(scratch, target)
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise CliError(
|
|
69
|
+
f"cannot replace {printable(path)}: {os_error_text(exc)}"
|
|
70
|
+
) from None
|
|
71
|
+
_sync_directory(directory)
|
|
72
|
+
except BaseException:
|
|
73
|
+
try:
|
|
74
|
+
os.unlink(scratch)
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
pass
|
|
77
|
+
raise
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The ``zipctl`` subcommands, one module each.
|
|
2
|
+
|
|
3
|
+
Every module exposes ``register(subparsers)``, which adds its parser and binds
|
|
4
|
+
the handler, so a command's arguments and behaviour live side by side. What
|
|
5
|
+
only commands share lives in :mod:`zipctl.cli.commands.helpers`; so does what
|
|
6
|
+
a single command needs beyond its handler (``collect`` and ``create_report`` for
|
|
7
|
+
``create``, ``extract_report`` for ``extract``), to keep one module per command.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The ``check-password`` 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.output_options import OutputArgs, add_output_options
|
|
10
|
+
from zipctl.cli.commands.helpers.passwords.options import (
|
|
11
|
+
PasswordArgs,
|
|
12
|
+
PasswordOptions,
|
|
13
|
+
add_password_options,
|
|
14
|
+
single_password,
|
|
15
|
+
)
|
|
16
|
+
from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
|
|
17
|
+
from zipctl.cli.context import Context
|
|
18
|
+
from zipctl.cli.errors import EXIT_FAILURE, EXIT_OK, CliError
|
|
19
|
+
from zipctl.cli.output import count, printable, write_json
|
|
20
|
+
from zipctl.exceptions import BadZipFile
|
|
21
|
+
from zipctl.zipfile.file import ZipFile
|
|
22
|
+
from zipctl.zipfile.info import ZipInfo
|
|
23
|
+
from zipctl.zipfile.password import (
|
|
24
|
+
MemberPasswordCheck,
|
|
25
|
+
PasswordCheckResult,
|
|
26
|
+
PasswordStatus,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_LINES = {
|
|
30
|
+
PasswordStatus.ACCEPTED: ("OK", ""),
|
|
31
|
+
PasswordStatus.REJECTED: ("FAILED", ": password does not match"),
|
|
32
|
+
PasswordStatus.CORRUPT: ("FAILED", ": password matches but the data is corrupt"),
|
|
33
|
+
PasswordStatus.UNENCRYPTED: ("SKIP", ": not encrypted"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CheckPasswordArgs(OutputArgs, PasswordArgs, Protocol):
|
|
38
|
+
archive: str
|
|
39
|
+
members: list[str]
|
|
40
|
+
full: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _summary(encrypted: int, accepted: int, full: bool) -> str:
|
|
44
|
+
if not encrypted:
|
|
45
|
+
return "No encrypted members to check"
|
|
46
|
+
text = f"Checked {count(encrypted, 'encrypted member')}: "
|
|
47
|
+
text += "all OK" if accepted == encrypted else f"{encrypted - accepted} failed"
|
|
48
|
+
if accepted and not full:
|
|
49
|
+
text += " (verifier only; use --full to authenticate the data)"
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check(
|
|
54
|
+
zf: ZipFile, infos: list[ZipInfo], args: CheckPasswordArgs, ctx: Context
|
|
55
|
+
) -> PasswordCheckResult:
|
|
56
|
+
if not any(info.is_encrypted for info in infos):
|
|
57
|
+
# Nothing to unlock, so do not ask for (or insist on) a password.
|
|
58
|
+
plain = PasswordStatus.UNENCRYPTED
|
|
59
|
+
return PasswordCheckResult(
|
|
60
|
+
tuple(MemberPasswordCheck(info.filename, plain) for info in infos)
|
|
61
|
+
)
|
|
62
|
+
password = single_password(PasswordOptions.from_args(args), ctx)
|
|
63
|
+
try:
|
|
64
|
+
return zf.check_password(password, members=infos, full=args.full)
|
|
65
|
+
except (BadZipFile, NotImplementedError) as exc:
|
|
66
|
+
raise CliError(f"cannot check the password: {printable(str(exc))}") from None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_check_password(args: CheckPasswordArgs, ctx: Context) -> int:
|
|
70
|
+
with open_archive(args.archive) as zf:
|
|
71
|
+
result = _check(zf, select_infos(zf.infolist(), args.members), args, ctx)
|
|
72
|
+
|
|
73
|
+
checks = result.members
|
|
74
|
+
encrypted = sum(check.status is not PasswordStatus.UNENCRYPTED for check in checks)
|
|
75
|
+
accepted = len(result.accepted)
|
|
76
|
+
if args.json:
|
|
77
|
+
write_json(
|
|
78
|
+
ctx.stdout,
|
|
79
|
+
{
|
|
80
|
+
"ok": result.ok,
|
|
81
|
+
"archive": args.archive,
|
|
82
|
+
"full": args.full,
|
|
83
|
+
"encrypted": encrypted,
|
|
84
|
+
"accepted": accepted,
|
|
85
|
+
"rejected": len(result.rejected),
|
|
86
|
+
"corrupt": len(result.corrupt),
|
|
87
|
+
"members": [
|
|
88
|
+
{"name": check.member, "status": check.status.value}
|
|
89
|
+
for check in checks
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
listed = False
|
|
95
|
+
for check in checks:
|
|
96
|
+
label, detail = _LINES[check.status]
|
|
97
|
+
if check.status in (PasswordStatus.REJECTED, PasswordStatus.CORRUPT) or (
|
|
98
|
+
args.verbose
|
|
99
|
+
):
|
|
100
|
+
ctx.out(f"{label:<8}{printable(check.member)}{detail}")
|
|
101
|
+
listed = True
|
|
102
|
+
if listed:
|
|
103
|
+
ctx.out()
|
|
104
|
+
ctx.out(_summary(encrypted, accepted, args.full))
|
|
105
|
+
return EXIT_OK if result.ok else EXIT_FAILURE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def register(subparsers: Subparsers) -> None:
|
|
109
|
+
parser = add_command(
|
|
110
|
+
subparsers,
|
|
111
|
+
"check-password",
|
|
112
|
+
cmd_check_password,
|
|
113
|
+
"check whether one password matches the encrypted members",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"archive",
|
|
117
|
+
metavar="ARCHIVE",
|
|
118
|
+
help="the encrypted archive to check the password against",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"members",
|
|
122
|
+
nargs="*",
|
|
123
|
+
metavar="MEMBER",
|
|
124
|
+
help=MEMBER_HELP,
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--full",
|
|
128
|
+
action="store_true",
|
|
129
|
+
help="also authenticate each member's data (definitive, but reads the "
|
|
130
|
+
"members). Default: check only the password verifier",
|
|
131
|
+
)
|
|
132
|
+
add_output_options(parser, verbose_help="also list members that match")
|
|
133
|
+
add_password_options(parser)
|