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,215 @@
|
|
|
1
|
+
"""Rendering :class:`~zipctl.ProgressEvent` on standard error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Generator, Iterable
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Protocol, TextIO
|
|
11
|
+
from unicodedata import east_asian_width
|
|
12
|
+
|
|
13
|
+
from zipctl.cli.output import human_size, printable
|
|
14
|
+
from zipctl.zipfile.extract import MemberStatus
|
|
15
|
+
from zipctl.zipfile.progress import ProgressCallback, ProgressEvent, ProgressPhase
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ProgressArgs",
|
|
19
|
+
"ProgressRenderer",
|
|
20
|
+
"StepReporter",
|
|
21
|
+
"add_progress_option",
|
|
22
|
+
"progress_renderer",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_FALLBACK_WIDTH = 80
|
|
26
|
+
_REDRAW_INTERVAL = 0.05 # seconds between redraws of the live status line
|
|
27
|
+
_STATUS_WORDS = {
|
|
28
|
+
MemberStatus.EXTRACTED: "OK",
|
|
29
|
+
MemberStatus.PREVIEWED: "OK",
|
|
30
|
+
MemberStatus.SKIPPED: "SKIP",
|
|
31
|
+
MemberStatus.FAILED: "FAILED",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _columns(text: str) -> int:
|
|
36
|
+
"""Terminal columns *text* takes: East Asian wide characters take two."""
|
|
37
|
+
return sum(2 if east_asian_width(char) in "WF" else 1 for char in text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _terminal_width(stream: TextIO) -> int:
|
|
41
|
+
"""Columns of the terminal *stream* is attached to (it is not standard output)."""
|
|
42
|
+
try:
|
|
43
|
+
return os.get_terminal_size(stream.fileno()).columns or _FALLBACK_WIDTH
|
|
44
|
+
except (OSError, ValueError, AttributeError): # not a terminal, or no fileno
|
|
45
|
+
return _FALLBACK_WIDTH
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _prefix(text: str, columns: int) -> str:
|
|
49
|
+
"""The longest start of *text* that fits in *columns*."""
|
|
50
|
+
used = 0
|
|
51
|
+
for index, char in enumerate(text):
|
|
52
|
+
used += _columns(char)
|
|
53
|
+
if used > columns:
|
|
54
|
+
return text[:index]
|
|
55
|
+
return text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shorten(text: str, width: int) -> str:
|
|
59
|
+
"""Trim *text* to *width* columns, keeping both ends of long names."""
|
|
60
|
+
if width < 8 or _columns(text) <= width:
|
|
61
|
+
return _prefix(text, max(width, 0))
|
|
62
|
+
keep = width - 3
|
|
63
|
+
head = keep // 2
|
|
64
|
+
tail = _prefix(text[::-1], keep - head)[::-1]
|
|
65
|
+
return f"{_prefix(text, head)}...{tail}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProgressRenderer:
|
|
69
|
+
"""A ``progress=`` callback that reports on *stream*.
|
|
70
|
+
|
|
71
|
+
On a terminal it keeps one status line up to date (``\\r`` rewrites); when
|
|
72
|
+
output is redirected it prints one plain line per finished member instead
|
|
73
|
+
(``[1/3] OK name``), so logs stay readable. The live line is redrawn at
|
|
74
|
+
most every 50 ms, except for the first event and the last member finishing,
|
|
75
|
+
which are always drawn.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
stream: TextIO,
|
|
81
|
+
*,
|
|
82
|
+
live: bool,
|
|
83
|
+
clock: Callable[[], float] = time.monotonic,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._stream: TextIO = stream
|
|
86
|
+
self._live: bool = live
|
|
87
|
+
self._clock: Callable[[], float] = clock
|
|
88
|
+
self._drawn: int = 0
|
|
89
|
+
self._last_draw: float | None = None
|
|
90
|
+
|
|
91
|
+
def __call__(self, event: ProgressEvent) -> None:
|
|
92
|
+
if self._live:
|
|
93
|
+
self._draw(event)
|
|
94
|
+
elif event.phase is ProgressPhase.FINISH and event.status is not None:
|
|
95
|
+
print(
|
|
96
|
+
f"[{event.member_index + 1}/{event.member_count}] "
|
|
97
|
+
f"{_STATUS_WORDS[event.status]:<8}{printable(event.member)}",
|
|
98
|
+
file=self._stream,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _due(self, event: ProgressEvent) -> bool:
|
|
102
|
+
if self._last_draw is None:
|
|
103
|
+
return True
|
|
104
|
+
last = (
|
|
105
|
+
event.phase is ProgressPhase.FINISH
|
|
106
|
+
and event.member_index + 1 == event.member_count
|
|
107
|
+
)
|
|
108
|
+
return last or self._clock() - self._last_draw >= _REDRAW_INTERVAL
|
|
109
|
+
|
|
110
|
+
def _draw(self, event: ProgressEvent) -> None:
|
|
111
|
+
if not self._due(event):
|
|
112
|
+
return
|
|
113
|
+
self._last_draw = self._clock()
|
|
114
|
+
if event.total_bytes:
|
|
115
|
+
percent = min(100, event.total_bytes_done * 100 // event.total_bytes)
|
|
116
|
+
else:
|
|
117
|
+
percent = 100 if event.phase is ProgressPhase.FINISH else 0
|
|
118
|
+
tail = (
|
|
119
|
+
f" {percent:3d}% {human_size(event.total_bytes_done)}"
|
|
120
|
+
f"/{human_size(event.total_bytes)}"
|
|
121
|
+
)
|
|
122
|
+
head = f"[{event.member_index + 1}/{event.member_count}] "
|
|
123
|
+
width = _terminal_width(self._stream) - 1
|
|
124
|
+
name = _shorten(printable(event.member), width - len(head) - len(tail))
|
|
125
|
+
self._write(head + name + tail)
|
|
126
|
+
|
|
127
|
+
def _write(self, line: str) -> None:
|
|
128
|
+
columns = _columns(line)
|
|
129
|
+
padding = " " * max(0, self._drawn - columns)
|
|
130
|
+
self._stream.write(f"\r{line}{padding}")
|
|
131
|
+
self._stream.flush()
|
|
132
|
+
self._drawn = columns
|
|
133
|
+
|
|
134
|
+
def clear(self) -> None:
|
|
135
|
+
"""Erase the status line, e.g. before a password prompt."""
|
|
136
|
+
if self._live and self._drawn:
|
|
137
|
+
self._stream.write("\r" + " " * self._drawn + "\r")
|
|
138
|
+
self._stream.flush()
|
|
139
|
+
self._drawn = 0
|
|
140
|
+
self._last_draw = None # what comes next is drawn at once
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Same as :meth:`clear`."""
|
|
144
|
+
self.clear()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ProgressArgs(Protocol):
|
|
148
|
+
"""What :func:`add_progress_option` leaves on the parsed arguments."""
|
|
149
|
+
|
|
150
|
+
progress: bool
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def add_progress_option(parser: argparse.ArgumentParser) -> None:
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--progress", action="store_true", help="report progress on standard error"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def progress_renderer(
|
|
161
|
+
stream: TextIO, enabled: bool
|
|
162
|
+
) -> Generator[ProgressRenderer | None]:
|
|
163
|
+
"""The renderer for ``--progress`` (``None`` without it), closed on the way out."""
|
|
164
|
+
renderer = ProgressRenderer(stream, live=stream.isatty()) if enabled else None
|
|
165
|
+
try:
|
|
166
|
+
yield renderer
|
|
167
|
+
finally:
|
|
168
|
+
if renderer is not None:
|
|
169
|
+
renderer.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class StepReporter:
|
|
173
|
+
"""Progress events for a command that does its own work member by member.
|
|
174
|
+
|
|
175
|
+
Extraction reports progress from inside the library; ``test`` and ``create``
|
|
176
|
+
call :meth:`start` and :meth:`finish` around each member instead. Sizes are
|
|
177
|
+
the declared ones, and there is one event pair per member (no byte-level
|
|
178
|
+
progress inside a member). Without a *callback* every call does nothing,
|
|
179
|
+
and *members* is never read (so it may be a lazy iterable).
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self, callback: ProgressCallback | None, members: Iterable[tuple[str, int]]
|
|
184
|
+
) -> None:
|
|
185
|
+
self._callback: ProgressCallback | None = callback
|
|
186
|
+
self._members: list[tuple[str, int]] = list(members) if callback else []
|
|
187
|
+
self._total: int = sum(size for _, size in self._members)
|
|
188
|
+
self._done: int = 0
|
|
189
|
+
|
|
190
|
+
def _event(
|
|
191
|
+
self, phase: ProgressPhase, index: int, status: MemberStatus | None = None
|
|
192
|
+
) -> ProgressEvent:
|
|
193
|
+
name, size = self._members[index]
|
|
194
|
+
finished = phase is ProgressPhase.FINISH
|
|
195
|
+
return ProgressEvent(
|
|
196
|
+
phase=phase,
|
|
197
|
+
member=name,
|
|
198
|
+
member_index=index,
|
|
199
|
+
member_count=len(self._members),
|
|
200
|
+
member_bytes=size if finished else 0,
|
|
201
|
+
member_total=size,
|
|
202
|
+
total_bytes=self._total,
|
|
203
|
+
total_bytes_done=self._done,
|
|
204
|
+
status=status,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def start(self, index: int) -> None:
|
|
208
|
+
if self._callback:
|
|
209
|
+
self._callback(self._event(ProgressPhase.START, index))
|
|
210
|
+
|
|
211
|
+
def finish(self, index: int, *, ok: bool) -> None:
|
|
212
|
+
if self._callback:
|
|
213
|
+
self._done += self._members[index][1]
|
|
214
|
+
status = MemberStatus.EXTRACTED if ok else MemberStatus.FAILED
|
|
215
|
+
self._callback(self._event(ProgressPhase.FINISH, index, status))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Choosing archive members with shell-style patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from bisect import bisect_left
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
|
|
9
|
+
from zipctl.cli.errors import UsageError
|
|
10
|
+
from zipctl.cli.output import did_you_mean, printable
|
|
11
|
+
from zipctl.zipfile.info import ZipInfo
|
|
12
|
+
from zipctl.zipfile.path.glob import Translator
|
|
13
|
+
|
|
14
|
+
__all__ = ["MEMBER_HELP", "glob_matcher", "select", "select_infos"]
|
|
15
|
+
|
|
16
|
+
MEMBER_HELP = (
|
|
17
|
+
"Member names or glob patterns; a directory name picks everything under it. "
|
|
18
|
+
"Default: every member"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_TRANSLATOR = Translator(seps="/")
|
|
22
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def glob_matcher(pattern: str) -> Callable[[str], bool]:
|
|
26
|
+
"""Return a predicate for member names matching *pattern*.
|
|
27
|
+
|
|
28
|
+
``*`` and ``?`` stay within one path segment, ``**`` crosses ``/``, and
|
|
29
|
+
``[...]`` is a character class. A name equal to *pattern* always matches,
|
|
30
|
+
so an archive member whose name contains glob characters can still be
|
|
31
|
+
named literally. Raises :class:`CliError` for a malformed pattern.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
compiled = re.compile(_TRANSLATOR.translate(pattern))
|
|
35
|
+
except (ValueError, re.error) as exc:
|
|
36
|
+
raise UsageError(
|
|
37
|
+
f"invalid pattern {printable(pattern)!r}: {printable(str(exc))}",
|
|
38
|
+
) from None
|
|
39
|
+
return lambda name: name == pattern or compiled.match(name) is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _under(ordered: list[str], directory: str) -> list[str]:
|
|
43
|
+
"""The names in sorted *ordered* that start with *directory* (ending in ``/``)."""
|
|
44
|
+
start = end = bisect_left(ordered, directory)
|
|
45
|
+
while end < len(ordered) and ordered[end].startswith(directory):
|
|
46
|
+
end += 1
|
|
47
|
+
return ordered[start:end]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def select(names: Sequence[str], patterns: Sequence[str], what: str) -> set[str]:
|
|
51
|
+
"""The *names* picked by *patterns* (all of them without any).
|
|
52
|
+
|
|
53
|
+
A plain name (no glob characters) that names a directory also picks
|
|
54
|
+
everything under it, as ``7z x`` and ``tar x`` do; ``unzip`` needs ``dir/*``.
|
|
55
|
+
A pattern that matches nothing is a usage error, raised before any work
|
|
56
|
+
is done: a typo must not leave members untouched while the command
|
|
57
|
+
reports success.
|
|
58
|
+
"""
|
|
59
|
+
if not patterns:
|
|
60
|
+
return set(names)
|
|
61
|
+
present = set(names)
|
|
62
|
+
ordered = sorted(present)
|
|
63
|
+
chosen: set[str] = set()
|
|
64
|
+
dead: list[str] = []
|
|
65
|
+
for pattern in patterns:
|
|
66
|
+
if _GLOB_CHARS.isdisjoint(pattern):
|
|
67
|
+
# A plain name matches itself, its directory entry and what is under
|
|
68
|
+
# it: a lookup instead of scanning every name. A malformed pattern
|
|
69
|
+
# needs a glob character, so nothing is left unchecked.
|
|
70
|
+
hit = present & {pattern, pattern + "/"}
|
|
71
|
+
if directory := pattern.rstrip("/"):
|
|
72
|
+
hit.update(_under(ordered, directory + "/"))
|
|
73
|
+
else:
|
|
74
|
+
matches = glob_matcher(pattern)
|
|
75
|
+
hit = {name for name in names if matches(name)}
|
|
76
|
+
if not hit:
|
|
77
|
+
dead.append(pattern)
|
|
78
|
+
chosen |= hit
|
|
79
|
+
if dead:
|
|
80
|
+
listed = ", ".join(
|
|
81
|
+
f"'{printable(pattern)}'"
|
|
82
|
+
+ ("" if _GLOB_CHARS & set(pattern) else did_you_mean(pattern, ordered))
|
|
83
|
+
for pattern in dead
|
|
84
|
+
)
|
|
85
|
+
raise UsageError(f"no {what} matches {listed}")
|
|
86
|
+
return chosen
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def select_infos(
|
|
90
|
+
infos: Sequence[ZipInfo], patterns: Sequence[str], what: str = "member"
|
|
91
|
+
) -> list[ZipInfo]:
|
|
92
|
+
"""The *infos* whose names *patterns* pick, in archive order."""
|
|
93
|
+
if not patterns:
|
|
94
|
+
return list(infos)
|
|
95
|
+
chosen = select([info.filename for info in infos], patterns, what)
|
|
96
|
+
return [info for info in infos if info.filename in chosen]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Reading text that a command line option names by file or standard input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from zipctl.cli.context import Context
|
|
8
|
+
from zipctl.cli.errors import UsageError, os_error_text
|
|
9
|
+
from zipctl.cli.output import printable
|
|
10
|
+
|
|
11
|
+
__all__ = ["read_text_source"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_text_source(source: str, ctx: Context, what: str) -> str:
|
|
15
|
+
"""The UTF-8 text of file *source* (``-`` is standard input), BOM removed.
|
|
16
|
+
|
|
17
|
+
*what* names the file in messages, e.g. ``"policy file"``.
|
|
18
|
+
"""
|
|
19
|
+
if source == "-":
|
|
20
|
+
named = f"the {what} on standard input"
|
|
21
|
+
ctx.claim_stdin(f"the {what}")
|
|
22
|
+
data = ctx.read_stdin()
|
|
23
|
+
else:
|
|
24
|
+
named = f"{what} {printable(source)}"
|
|
25
|
+
try:
|
|
26
|
+
data = Path(source).read_bytes()
|
|
27
|
+
except OSError as exc:
|
|
28
|
+
raise UsageError(f"cannot read {named}: {os_error_text(exc)}") from None
|
|
29
|
+
try:
|
|
30
|
+
return data.decode("utf-8-sig")
|
|
31
|
+
except UnicodeDecodeError:
|
|
32
|
+
raise UsageError(f"{named} is not valid UTF-8") from None
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The ``inspect`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from zipctl.cli.archive import 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.policy_options import (
|
|
12
|
+
PolicyArgs,
|
|
13
|
+
add_policy_options,
|
|
14
|
+
load_policy,
|
|
15
|
+
)
|
|
16
|
+
from zipctl.cli.context import Context
|
|
17
|
+
from zipctl.cli.errors import EXIT_FAILURE, EXIT_OK
|
|
18
|
+
from zipctl.cli.output import (
|
|
19
|
+
count,
|
|
20
|
+
human_size,
|
|
21
|
+
printable,
|
|
22
|
+
write_json,
|
|
23
|
+
)
|
|
24
|
+
from zipctl.zipfile.extract import (
|
|
25
|
+
ViolationAction,
|
|
26
|
+
)
|
|
27
|
+
from zipctl.zipfile.inspection import InspectionResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InspectArgs(OutputArgs, PolicyArgs, Protocol):
|
|
31
|
+
archive: str
|
|
32
|
+
destination: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _section(title: str, names: tuple[str, ...], flagged: set[str]) -> list[str]:
|
|
36
|
+
"""A titled list of *names*, leaving out those a violation already reports."""
|
|
37
|
+
names = tuple(name for name in names if name not in flagged)
|
|
38
|
+
if not names:
|
|
39
|
+
return []
|
|
40
|
+
return [f" {title} ({len(names)}):", *(f" {printable(name)}" for name in names)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _violations(result: InspectionResult) -> list[str]:
|
|
44
|
+
if not result.violations:
|
|
45
|
+
return []
|
|
46
|
+
return [
|
|
47
|
+
f" Violations ({len(result.violations)}):",
|
|
48
|
+
*(
|
|
49
|
+
f" {violation.action.value.upper():<7}{printable(violation.member)}: "
|
|
50
|
+
f"{printable(violation.message)}"
|
|
51
|
+
for violation in result.violations
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_inspection(
|
|
57
|
+
ctx: Context,
|
|
58
|
+
archive: str,
|
|
59
|
+
destination: str,
|
|
60
|
+
result: InspectionResult,
|
|
61
|
+
errors: int,
|
|
62
|
+
quiet: bool,
|
|
63
|
+
) -> None:
|
|
64
|
+
if quiet:
|
|
65
|
+
if errors: # the details alone: no verdict, as when nothing is wrong
|
|
66
|
+
ctx.out("Details:")
|
|
67
|
+
for line in _violations(result):
|
|
68
|
+
ctx.out(line)
|
|
69
|
+
return
|
|
70
|
+
verdict = "OK" if not errors else f"FAILED ({count(errors, 'error')})"
|
|
71
|
+
directories = sum(1 for member in result.members if member.is_directory)
|
|
72
|
+
flagged = {violation.member for violation in result.violations}
|
|
73
|
+
ctx.out(f"Archive: {printable(archive)}")
|
|
74
|
+
ctx.out(f"Destination: {printable(destination)}")
|
|
75
|
+
ctx.out(
|
|
76
|
+
f"Members: {result.total_entries} "
|
|
77
|
+
f"({count(directories, 'directory', 'directories')})"
|
|
78
|
+
)
|
|
79
|
+
ctx.out(
|
|
80
|
+
f"Size: {human_size(result.total_uncompressed_size)} "
|
|
81
|
+
f"({human_size(result.total_compressed_size)} compressed)"
|
|
82
|
+
)
|
|
83
|
+
details = [
|
|
84
|
+
*_violations(result),
|
|
85
|
+
*_section("Encrypted members", result.encrypted_members, flagged),
|
|
86
|
+
*_section("Suspicious paths", result.suspicious_paths, flagged),
|
|
87
|
+
*_section("Symlinks", result.symlinks, flagged),
|
|
88
|
+
*_section("Special files", result.special_files, flagged),
|
|
89
|
+
*_section("Large members", result.large_members, flagged),
|
|
90
|
+
*_section("Ratio outliers", result.compress_ratio_outliers, flagged),
|
|
91
|
+
*_section("Duplicate names", result.duplicate_member_names, flagged),
|
|
92
|
+
]
|
|
93
|
+
ctx.out(f"Result: {verdict}")
|
|
94
|
+
if details:
|
|
95
|
+
ctx.out("")
|
|
96
|
+
ctx.out("Details:")
|
|
97
|
+
for line in details:
|
|
98
|
+
ctx.out(line)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_inspect(args: InspectArgs, ctx: Context) -> int:
|
|
102
|
+
policy = load_policy(args, ctx)
|
|
103
|
+
destination = os.path.abspath(args.destination or os.getcwd())
|
|
104
|
+
with open_archive(args.archive) as zf:
|
|
105
|
+
result = zf.inspect(destination, policy)
|
|
106
|
+
errors = sum(v.action is ViolationAction.ERROR for v in result.violations)
|
|
107
|
+
if args.json:
|
|
108
|
+
write_json(
|
|
109
|
+
ctx.stdout,
|
|
110
|
+
{
|
|
111
|
+
"ok": errors == 0,
|
|
112
|
+
"archive": args.archive,
|
|
113
|
+
"destination": destination,
|
|
114
|
+
"inspection": result,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
_render_inspection(
|
|
119
|
+
ctx,
|
|
120
|
+
args.archive,
|
|
121
|
+
destination,
|
|
122
|
+
result,
|
|
123
|
+
errors,
|
|
124
|
+
args.quiet,
|
|
125
|
+
)
|
|
126
|
+
return EXIT_FAILURE if errors else EXIT_OK
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register(subparsers: Subparsers) -> None:
|
|
130
|
+
parser = add_command(
|
|
131
|
+
subparsers,
|
|
132
|
+
"inspect",
|
|
133
|
+
cmd_inspect,
|
|
134
|
+
"report what extracting would flag, without reading any data",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument("archive", metavar="ARCHIVE", help="the archive to inspect")
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"-d",
|
|
139
|
+
"--destination",
|
|
140
|
+
metavar="DIR",
|
|
141
|
+
help="directory the members would be extracted into, used to detect "
|
|
142
|
+
"overwrites. Default: the current directory",
|
|
143
|
+
)
|
|
144
|
+
add_output_options(parser, quiet=True)
|
|
145
|
+
add_policy_options(parser)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The ``list`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from zipctl.cli.archive import open_archive
|
|
10
|
+
from zipctl.cli.commands.helpers.command import Subparsers, add_command
|
|
11
|
+
from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
|
|
12
|
+
from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
|
|
13
|
+
from zipctl.cli.context import Context
|
|
14
|
+
from zipctl.cli.errors import EXIT_OK
|
|
15
|
+
from zipctl.cli.methods import compression_label, method_of
|
|
16
|
+
from zipctl.cli.output import (
|
|
17
|
+
count,
|
|
18
|
+
format_table,
|
|
19
|
+
human_size,
|
|
20
|
+
printable,
|
|
21
|
+
write_json,
|
|
22
|
+
)
|
|
23
|
+
from zipctl.zipfile.file import ZipFile
|
|
24
|
+
from zipctl.zipfile.info import ZipInfo
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ListArgs(OutputArgs, Protocol):
|
|
28
|
+
archive: str
|
|
29
|
+
members: list[str]
|
|
30
|
+
long: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _timestamp(info: ZipInfo, separator: str) -> str:
|
|
34
|
+
year, month, day, hour, minute, second = info.date_time
|
|
35
|
+
date = f"{year:04d}-{month:02d}-{day:02d}"
|
|
36
|
+
return f"{date}{separator}{hour:02d}:{minute:02d}:{second:02d}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mode_text(info: ZipInfo) -> str:
|
|
40
|
+
mode = info.unix_mode
|
|
41
|
+
if not mode:
|
|
42
|
+
return "-"
|
|
43
|
+
if not stat.S_IFMT(mode): # permissions only: the kind comes from the name
|
|
44
|
+
mode |= stat.S_IFDIR if info.is_dir() else stat.S_IFREG
|
|
45
|
+
return stat.filemode(mode)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _saved(compressed: int, original: int) -> str:
|
|
49
|
+
"""The share of *original* that compression saved (``-`` for no data).
|
|
50
|
+
|
|
51
|
+
Like ``unzip -v``: stored data saves ``0%``, data that grew is negative.
|
|
52
|
+
"""
|
|
53
|
+
return f"{round(100 * (1 - compressed / original))}%" if original else "-"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Listed:
|
|
58
|
+
"""One member as ``list --json`` reports it."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
directory: bool
|
|
62
|
+
is_symlink: bool
|
|
63
|
+
size: int
|
|
64
|
+
compressed_size: int
|
|
65
|
+
modified: str
|
|
66
|
+
compression: str
|
|
67
|
+
encryption: str
|
|
68
|
+
crc32: str
|
|
69
|
+
mode: str | None
|
|
70
|
+
comment: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _listed(info: ZipInfo) -> Listed:
|
|
74
|
+
mode = info.unix_mode
|
|
75
|
+
return Listed(
|
|
76
|
+
info.filename,
|
|
77
|
+
info.is_dir(),
|
|
78
|
+
info.is_symlink(),
|
|
79
|
+
info.file_size,
|
|
80
|
+
info.compress_size,
|
|
81
|
+
_timestamp(info, "T"),
|
|
82
|
+
compression_label(info),
|
|
83
|
+
method_of(info).label,
|
|
84
|
+
f"{info.CRC:08x}",
|
|
85
|
+
f"{mode:o}" if mode else None,
|
|
86
|
+
info.comment.decode("utf-8", "replace"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _list_json(ctx: Context, zf: ZipFile, archive: str, infos: list[ZipInfo]) -> None:
|
|
91
|
+
write_json(
|
|
92
|
+
ctx.stdout,
|
|
93
|
+
{
|
|
94
|
+
"ok": True,
|
|
95
|
+
"archive": archive,
|
|
96
|
+
"comment": zf.comment.decode("utf-8", "replace"),
|
|
97
|
+
"member_count": len(infos),
|
|
98
|
+
"members": [_listed(info) for info in infos],
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _crc_text(info: ZipInfo) -> str:
|
|
104
|
+
"""The CRC-32, or ``-`` where AE-2 does not store it."""
|
|
105
|
+
return f"{info.CRC:08x}" if info.stores_crc else "-"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _list_long(ctx: Context, zf: ZipFile, infos: list[ZipInfo]) -> None:
|
|
109
|
+
encrypted = any(method_of(info).is_encrypted for info in infos)
|
|
110
|
+
headers = ["Mode", "Length", "Compressed", "Saved", "Modified", "Method"]
|
|
111
|
+
if encrypted:
|
|
112
|
+
headers.append("Encryption")
|
|
113
|
+
headers += ["CRC-32", "Name"]
|
|
114
|
+
rows: list[list[str]] = []
|
|
115
|
+
for info in infos:
|
|
116
|
+
row = [
|
|
117
|
+
_mode_text(info),
|
|
118
|
+
str(info.file_size),
|
|
119
|
+
str(info.compress_size),
|
|
120
|
+
_saved(info.compress_size, info.file_size),
|
|
121
|
+
_timestamp(info, " "),
|
|
122
|
+
compression_label(info),
|
|
123
|
+
]
|
|
124
|
+
if encrypted:
|
|
125
|
+
row.append(method_of(info).label)
|
|
126
|
+
rows.append([*row, _crc_text(info), printable(info.filename)])
|
|
127
|
+
for line in format_table(headers, rows, right_aligned=(1, 2, 3)):
|
|
128
|
+
ctx.out(line)
|
|
129
|
+
total = sum(info.file_size for info in infos)
|
|
130
|
+
stored = sum(info.compress_size for info in infos)
|
|
131
|
+
footer = f"{count(len(infos), 'member')}, {human_size(total)}"
|
|
132
|
+
if total:
|
|
133
|
+
footer += f" ({human_size(stored)} compressed, {_saved(stored, total)} saved)"
|
|
134
|
+
ctx.out("")
|
|
135
|
+
ctx.out(footer)
|
|
136
|
+
if zf.comment:
|
|
137
|
+
ctx.out("")
|
|
138
|
+
ctx.out("Comment:")
|
|
139
|
+
for line in zf.comment.decode("utf-8", "replace").splitlines():
|
|
140
|
+
ctx.out(f" {printable(line)}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_list(args: ListArgs, ctx: Context) -> int:
|
|
144
|
+
with open_archive(args.archive) as zf:
|
|
145
|
+
infos = select_infos(zf.infolist(), args.members)
|
|
146
|
+
if args.json:
|
|
147
|
+
_list_json(ctx, zf, args.archive, infos)
|
|
148
|
+
elif args.long:
|
|
149
|
+
_list_long(ctx, zf, infos)
|
|
150
|
+
else:
|
|
151
|
+
for info in infos:
|
|
152
|
+
ctx.out(printable(info.filename))
|
|
153
|
+
return EXIT_OK
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def register(subparsers: Subparsers) -> None:
|
|
157
|
+
parser = add_command(subparsers, "list", cmd_list, "list archive members")
|
|
158
|
+
parser.add_argument("archive", metavar="ARCHIVE", help="the archive to list")
|
|
159
|
+
parser.add_argument("members", nargs="*", metavar="MEMBER", help=MEMBER_HELP)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"-l",
|
|
162
|
+
"--long",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="show modes, sizes, space saved, dates, methods and the archive comment",
|
|
165
|
+
)
|
|
166
|
+
add_output_options(parser)
|