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