fuscan 0.1.1__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 (66) hide show
  1. fuscan/__init__.py +10 -0
  2. fuscan/__main__.py +8 -0
  3. fuscan/archive/__init__.py +46 -0
  4. fuscan/archive/base.py +102 -0
  5. fuscan/archive/rar_reader.py +111 -0
  6. fuscan/archive/scanner.py +270 -0
  7. fuscan/archive/zip_reader.py +96 -0
  8. fuscan/assets/icons/all_disk.svg +1 -0
  9. fuscan/assets/icons/disk.svg +1 -0
  10. fuscan/assets/icons/folder.svg +1 -0
  11. fuscan/assets/icons/hard_disk.svg +1 -0
  12. fuscan/assets/icons/history.svg +1 -0
  13. fuscan/assets/icons/load_list.svg +1 -0
  14. fuscan/assets/icons/pause.svg +1 -0
  15. fuscan/assets/icons/rescan.svg +1 -0
  16. fuscan/assets/icons/right.svg +1 -0
  17. fuscan/assets/icons/scan.svg +1 -0
  18. fuscan/assets/icons/stop.svg +1 -0
  19. fuscan/builtin/__init__.py +60 -0
  20. fuscan/builtin/rules.yaml +166 -0
  21. fuscan/cli.py +360 -0
  22. fuscan/config.py +95 -0
  23. fuscan/extractors/__init__.py +52 -0
  24. fuscan/extractors/base.py +106 -0
  25. fuscan/extractors/odf.py +48 -0
  26. fuscan/extractors/office.py +106 -0
  27. fuscan/extractors/pdf.py +59 -0
  28. fuscan/extractors/registry.py +36 -0
  29. fuscan/extractors/spreadsheet.py +112 -0
  30. fuscan/extractors/text.py +131 -0
  31. fuscan/extractors/wps.py +161 -0
  32. fuscan/gui/__init__.py +24 -0
  33. fuscan/gui/app.py +65 -0
  34. fuscan/gui/detail_dialog.py +292 -0
  35. fuscan/gui/detail_dialog.ui +175 -0
  36. fuscan/gui/detail_dialog_ui.py +143 -0
  37. fuscan/gui/main_window.py +1415 -0
  38. fuscan/gui/main_window.ui +981 -0
  39. fuscan/gui/main_window_ui.py +666 -0
  40. fuscan/gui/rule_editor.py +144 -0
  41. fuscan/gui/rule_editor.ui +116 -0
  42. fuscan/gui/rule_editor_ui.py +108 -0
  43. fuscan/gui/styles.qss +706 -0
  44. fuscan/gui/worker.py +156 -0
  45. fuscan/py.typed +0 -0
  46. fuscan/rules/__init__.py +51 -0
  47. fuscan/rules/errors.py +17 -0
  48. fuscan/rules/merge.py +63 -0
  49. fuscan/rules/model.py +128 -0
  50. fuscan/rules/parser.py +204 -0
  51. fuscan/scanner/__init__.py +64 -0
  52. fuscan/scanner/context.py +93 -0
  53. fuscan/scanner/matchers.py +224 -0
  54. fuscan/scanner/result.py +93 -0
  55. fuscan/scanner/scanner.py +369 -0
  56. fuscan/scanner/walker.py +118 -0
  57. fuscan/watcher/__init__.py +34 -0
  58. fuscan/watcher/ignore_dirs.py +80 -0
  59. fuscan/watcher/incremental.py +210 -0
  60. fuscan/watcher/monitor.py +243 -0
  61. fuscan/watcher/tray.py +287 -0
  62. fuscan-0.1.1.dist-info/METADATA +191 -0
  63. fuscan-0.1.1.dist-info/RECORD +66 -0
  64. fuscan-0.1.1.dist-info/WHEEL +4 -0
  65. fuscan-0.1.1.dist-info/entry_points.txt +2 -0
  66. fuscan-0.1.1.dist-info/licenses/LICENSE +21 -0
fuscan/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """fuscan: 通用文件扫描器。
2
+
3
+ 基于 YAML 规则的多格式内容扫描工具,支持 CLI/GUI 与托盘驻守。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __all__ = ["__version__"]
9
+
10
+ __version__ = "0.1.1"
fuscan/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """模块入口,支持 `python -m fuscan` 调用。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fuscan.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,46 @@
1
+ """压缩文件扫描模块。
2
+
3
+ 提供 ZIP/RAR 压缩包条目列举与内容读取能力,供 ArchiveScanner 调用。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from fuscan.archive.base import (
9
+ ArchiveEntry,
10
+ ArchiveError,
11
+ ArchiveReader,
12
+ ArchiveReaderFactory,
13
+ default_factory,
14
+ get_reader,
15
+ )
16
+ from fuscan.archive.rar_reader import RarReader
17
+ from fuscan.archive.zip_reader import ZipReader
18
+
19
+ __all__ = [
20
+ "ArchiveEntry",
21
+ "ArchiveError",
22
+ "ArchiveReader",
23
+ "ArchiveReaderFactory",
24
+ "ArchiveScanner",
25
+ "RarReader",
26
+ "ZipReader",
27
+ "default_factory",
28
+ "get_reader",
29
+ "register_all",
30
+ ]
31
+
32
+
33
+ def register_all(factory: ArchiveReaderFactory = default_factory) -> None:
34
+ """注册所有内置压缩文件读取器(幂等)。"""
35
+ if factory.get("zip") is None:
36
+ factory.register("zip", ZipReader)
37
+ if factory.get("rar") is None:
38
+ factory.register("rar", RarReader)
39
+
40
+
41
+ # 模块导入即注册
42
+ register_all()
43
+
44
+
45
+ # 延迟导入避免循环依赖
46
+ from fuscan.archive.scanner import ArchiveScanner # noqa: E402
fuscan/archive/base.py ADDED
@@ -0,0 +1,102 @@
1
+ """压缩文件扫描抽象层。
2
+
3
+ 定义 ArchiveEntry 数据结构与 ArchiveReader 抽象基类。
4
+ 具体实现见 zip_reader.py 与 rar_reader.py。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ __all__ = [
14
+ "ArchiveEntry",
15
+ "ArchiveError",
16
+ "ArchiveReader",
17
+ "ArchiveReaderFactory",
18
+ "default_factory",
19
+ "get_reader",
20
+ ]
21
+
22
+
23
+ class ArchiveError(Exception):
24
+ """压缩文件相关错误。"""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ArchiveEntry:
29
+ """压缩包内文件条目。"""
30
+
31
+ archive_path: Path
32
+ entry_name: str
33
+ size: int
34
+ compressed_size: int
35
+ is_dir: bool = False
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ """条目文件名(不含目录部分)。"""
40
+ return Path(self.entry_name).name
41
+
42
+ @property
43
+ def extension(self) -> str:
44
+ """条目扩展名(不含点,小写)。"""
45
+ return Path(self.entry_name).suffix.lower().lstrip(".")
46
+
47
+ @property
48
+ def display_path(self) -> str:
49
+ """展示用路径:archive.zip!inner/file.txt。"""
50
+ return f"{self.archive_path}!{self.entry_name}"
51
+
52
+
53
+ class ArchiveReader(ABC):
54
+ """压缩文件读取器抽象基类。"""
55
+
56
+ @property
57
+ @abstractmethod
58
+ def supported_extensions(self) -> tuple[str, ...]:
59
+ """支持的压缩文件扩展名。"""
60
+
61
+ @abstractmethod
62
+ def list_entries(self) -> list[ArchiveEntry]:
63
+ """列出压缩包内所有条目。"""
64
+
65
+ @abstractmethod
66
+ def read_entry(self, entry_name: str) -> bytes:
67
+ """读取条目内容到内存。
68
+
69
+ :raises ArchiveError: 读取失败(加密、损坏等)
70
+ """
71
+
72
+
73
+ class ArchiveReaderFactory:
74
+ """压缩文件读取器工厂:按扩展名分发。"""
75
+
76
+ def __init__(self) -> None:
77
+ self._factories: dict[str, type[ArchiveReader]] = {}
78
+
79
+ def register(self, extension: str, reader_cls: type[ArchiveReader]) -> None:
80
+ self._factories[extension.lower().lstrip(".")] = reader_cls
81
+
82
+ def get(self, extension: str) -> type[ArchiveReader] | None:
83
+ return self._factories.get(extension.lower().lstrip("."))
84
+
85
+ def create(self, path: Path, password: str | None = None) -> ArchiveReader | None:
86
+ """按扩展名创建读取器实例。"""
87
+ ext = path.suffix.lower().lstrip(".")
88
+ reader_cls = self._factories.get(ext)
89
+ if reader_cls is None:
90
+ return None
91
+ try:
92
+ return reader_cls(path, password=password) # type: ignore[call-arg]
93
+ except TypeError:
94
+ return reader_cls(path) # type: ignore[call-arg]
95
+
96
+
97
+ default_factory = ArchiveReaderFactory()
98
+
99
+
100
+ def get_reader(path: Path, password: str | None = None) -> ArchiveReader | None:
101
+ """从默认工厂创建读取器。"""
102
+ return default_factory.create(path, password=password)
@@ -0,0 +1,111 @@
1
+ """RAR 压缩文件读取器。
2
+
3
+ 基于 rarfile 第三方库实现,依赖系统 unrar 工具。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from fuscan.archive.base import ArchiveEntry, ArchiveError, ArchiveReader
12
+
13
+ __all__ = ["RarReader"]
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RarReader(ArchiveReader):
19
+ """RAR 压缩包读取器。
20
+
21
+ 使用 rarfile 库读取 RAR 格式(需系统安装 unrar 工具)。
22
+ 加密条目需要密码;未提供密码或密码错误时跳过并记录。
23
+ """
24
+
25
+ def __init__(self, path: Path, password: str | None = None) -> None:
26
+ try:
27
+ import rarfile # 惰性导入,避免无 unrar 环境下的导入失败
28
+ except ImportError as exc:
29
+ raise ArchiveError("rarfile 库未安装,无法读取 RAR 文件") from exc
30
+
31
+ self._path = path
32
+ self._password = password
33
+ try:
34
+ self._rar = rarfile.RarFile(str(path))
35
+ except rarfile.BadRarFile as exc:
36
+ raise ArchiveError(f"损坏的 RAR 文件: {path}") from exc
37
+ except rarfile.NotRarFile as exc:
38
+ raise ArchiveError(f"不是 RAR 文件: {path}") from exc
39
+ except OSError as exc:
40
+ raise ArchiveError(f"无法打开 RAR 文件: {path}: {exc}") from exc
41
+ except Exception as exc: # unrar 工具缺失等情况
42
+ raise ArchiveError(f"打开 RAR 文件失败(可能缺少 unrar 工具): {path}: {exc}") from exc
43
+
44
+ @property
45
+ def supported_extensions(self) -> tuple[str, ...]:
46
+ return ("rar",)
47
+
48
+ def list_entries(self) -> list[ArchiveEntry]:
49
+ """列出压缩包内所有条目。"""
50
+ entries: list[ArchiveEntry] = []
51
+ for info in self._rar.infolist():
52
+ entries.append(
53
+ ArchiveEntry(
54
+ archive_path=self._path,
55
+ entry_name=info.filename,
56
+ size=getattr(info, "file_size", 0),
57
+ compressed_size=getattr(info, "compress_size", 0),
58
+ is_dir=bool(getattr(info, "isdir", False)),
59
+ )
60
+ )
61
+ return entries
62
+
63
+ def read_entry(self, entry_name: str) -> bytes:
64
+ """读取条目内容。
65
+
66
+ :raises ArchiveError: 读取失败(加密、损坏、找不到条目等)
67
+ """
68
+ try:
69
+ import rarfile
70
+ except ImportError as exc: # pragma: no cover - 构造时已校验
71
+ raise ArchiveError("rarfile 库未安装") from exc
72
+
73
+ try:
74
+ info = self._rar.getinfo(entry_name)
75
+ except KeyError as exc:
76
+ raise ArchiveError(f"RAR 条目不存在: {entry_name}") from exc
77
+ except Exception as exc:
78
+ raise ArchiveError(f"获取 RAR 条目信息失败: {entry_name}: {exc}") from exc
79
+
80
+ if getattr(info, "isdir", False):
81
+ return b""
82
+
83
+ # 加密条目处理
84
+ needs_password = bool(getattr(info, "needs_password", False)) or self._password is not None
85
+ if getattr(info, "needs_password", False) and self._password is None:
86
+ logger.info("RAR 条目加密且未提供密码,跳过: %s!%s", self._path, entry_name)
87
+ raise ArchiveError(f"加密条目未提供密码: {entry_name}")
88
+
89
+ try:
90
+ if needs_password and self._password:
91
+ return self._rar.read(entry_name, pwd=self._password)
92
+ return self._rar.read(entry_name)
93
+ except rarfile.PasswordRequired as exc:
94
+ raise ArchiveError(f"RAR 条目需要密码: {entry_name}: {exc}") from exc
95
+ except rarfile.BadRarFile as exc:
96
+ raise ArchiveError(f"RAR 条目损坏: {entry_name}: {exc}") from exc
97
+ except Exception as exc:
98
+ raise ArchiveError(f"RAR 条目读取失败: {entry_name}: {exc}") from exc
99
+
100
+ def close(self) -> None:
101
+ """关闭 RAR 文件句柄。"""
102
+ try:
103
+ self._rar.close()
104
+ except Exception: # pragma: no cover - 关闭异常无需上报
105
+ logger.debug("关闭 RAR 文件句柄失败: %s", self._path, exc_info=True)
106
+
107
+ def __enter__(self) -> RarReader:
108
+ return self
109
+
110
+ def __exit__(self, exc_type, exc, tb) -> None:
111
+ self.close()
@@ -0,0 +1,270 @@
1
+ """压缩文件扫描器:对压缩包内条目应用规则集。
2
+
3
+ 读取压缩包(ZIP/RAR)内文件,为每个条目构造合成 FileEntry,
4
+ 通过临时文件复用已有提取器链,最终输出 ScanResult 列表。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from fuscan.archive.base import (
15
+ ArchiveEntry,
16
+ ArchiveError,
17
+ ArchiveReader,
18
+ get_reader,
19
+ )
20
+ from fuscan.extractors import extract_content
21
+ from fuscan.rules.model import Rule, RuleSet
22
+ from fuscan.scanner.context import FileEntry, MatchContext
23
+ from fuscan.scanner.matchers import Matcher, build_matcher
24
+ from fuscan.scanner.result import RuleHit, ScanResult
25
+
26
+ __all__ = ["ArchiveScanner"]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class ArchiveScanner:
32
+ """压缩文件扫描器:对单个压缩包内所有文件应用规则。
33
+
34
+ - 构造时一次性编译规则集为 Matcher 列表
35
+ - 通过 :func:`get_reader` 工厂分发到 ZipReader/RarReader
36
+ - 内容提取策略:读取条目字节 → 写入临时文件 → 调用 extract_content
37
+ - 加密条目未提供密码时跳过并记录错误
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ ruleset: RuleSet,
43
+ password: str | None = None,
44
+ max_entry_size: int = 50 * 1024 * 1024,
45
+ ) -> None:
46
+ self._ruleset = ruleset
47
+ self._password = password
48
+ self._max_entry_size = max_entry_size
49
+ self._compiled: list[tuple[Rule, Matcher]] = [(rule, build_matcher(rule.match)) for rule in ruleset.rules]
50
+
51
+ def scan_archive(self, archive_path: Path) -> tuple[ScanResult, ...]:
52
+ """扫描压缩包内所有条目,返回结果元组。
53
+
54
+ 压缩包无法打开时返回单条错误结果。
55
+ """
56
+ try:
57
+ reader = get_reader(archive_path, password=self._password)
58
+ except ArchiveError:
59
+ logger.warning("打开压缩包失败: %s", archive_path, exc_info=True)
60
+ return (
61
+ ScanResult(
62
+ path=archive_path,
63
+ size=0,
64
+ hits=(),
65
+ errors=1,
66
+ ),
67
+ )
68
+ if reader is None:
69
+ logger.debug("无注册读取器,跳过压缩包: %s", archive_path)
70
+ return ()
71
+
72
+ try:
73
+ entries = reader.list_entries()
74
+ except ArchiveError:
75
+ logger.warning("列出压缩包条目失败: %s", archive_path, exc_info=True)
76
+ self._close_reader(reader)
77
+ return (
78
+ ScanResult(
79
+ path=archive_path,
80
+ size=0,
81
+ hits=(),
82
+ errors=1,
83
+ ),
84
+ )
85
+
86
+ results: list[ScanResult] = []
87
+ for entry in entries:
88
+ if entry.is_dir:
89
+ continue
90
+ result = self._scan_entry(archive_path, entry, reader)
91
+ results.append(result)
92
+
93
+ self._close_reader(reader)
94
+ return tuple(results)
95
+
96
+ def _scan_entry(
97
+ self,
98
+ archive_path: Path,
99
+ entry: ArchiveEntry,
100
+ reader: ArchiveReader,
101
+ ) -> ScanResult:
102
+ """对压缩包内单个条目应用规则。"""
103
+ file_entry = FileEntry(
104
+ path=Path(entry.display_path),
105
+ name=entry.name,
106
+ size=entry.size,
107
+ mtime=0.0,
108
+ extension=entry.extension,
109
+ is_dir=False,
110
+ )
111
+
112
+ def content_provider(_fe: FileEntry) -> str:
113
+ return self._read_entry_content(archive_path, entry, reader)
114
+
115
+ context = MatchContext(file_entry, content_provider=content_provider)
116
+ hits: list[RuleHit] = []
117
+ rule_errors = 0
118
+
119
+ for rule, matcher in self._compiled:
120
+ if rule.file_extensions and entry.extension not in rule.file_extensions:
121
+ continue
122
+ try:
123
+ result = matcher.matches(context)
124
+ except Exception:
125
+ rule_errors += 1
126
+ logger.warning(
127
+ "规则 %s 求值失败 %s",
128
+ rule.name,
129
+ entry.display_path,
130
+ exc_info=True,
131
+ )
132
+ continue
133
+ if result.matched:
134
+ hits.append(RuleHit(rule_name=rule.name, severity=rule.severity, detail=result.detail))
135
+
136
+ return ScanResult(
137
+ path=file_entry.path,
138
+ size=file_entry.size,
139
+ hits=tuple(hits),
140
+ errors=rule_errors,
141
+ )
142
+
143
+ def _read_entry_content(
144
+ self,
145
+ _archive_path: Path,
146
+ entry: ArchiveEntry,
147
+ reader: ArchiveReader,
148
+ ) -> str:
149
+ """读取条目字节并通过临时文件复用提取器链。"""
150
+ if entry.size > self._max_entry_size:
151
+ logger.debug("条目过大,跳过内容提取: %s", entry.display_path)
152
+ return ""
153
+
154
+ try:
155
+ data = reader.read_entry(entry.entry_name)
156
+ except ArchiveError as exc:
157
+ logger.info("读取压缩包条目失败: %s", exc)
158
+ return ""
159
+
160
+ if not data:
161
+ return ""
162
+
163
+ # 纯文本类条目直接解码
164
+ if entry.extension in _TEXT_EXTENSIONS:
165
+ return _decode_bytes(data)
166
+
167
+ # 有注册提取器的格式走临时文件提取
168
+ if _has_extractor(entry.extension):
169
+ return self._extract_via_temp(data, entry)
170
+
171
+ # 其他情况按文本解码
172
+ return _decode_bytes(data)
173
+
174
+ def _extract_via_temp(self, data: bytes, entry: ArchiveEntry) -> str:
175
+ """写入临时文件并调用提取器。"""
176
+ suffix = f".{entry.extension}" if entry.extension else ""
177
+ fd, tmp_name = tempfile.mkstemp(suffix=suffix)
178
+ tmp_path = Path(tmp_name)
179
+ os.close(fd)
180
+ try:
181
+ tmp_path.write_bytes(data)
182
+ try:
183
+ return extract_content(tmp_path)
184
+ except Exception:
185
+ logger.debug("临时文件提取失败: %s", entry.display_path, exc_info=True)
186
+ return _decode_bytes(data)
187
+ finally:
188
+ _safe_unlink(tmp_path)
189
+
190
+ @staticmethod
191
+ def _close_reader(reader: ArchiveReader) -> None:
192
+ """安全关闭读取器。"""
193
+ close = getattr(reader, "close", None)
194
+ if close is None:
195
+ return
196
+ try:
197
+ close()
198
+ except Exception: # pragma: no cover - 关闭异常无需上报
199
+ logger.debug("关闭读取器失败", exc_info=True)
200
+
201
+
202
+ def _safe_unlink(path: Path) -> None:
203
+ """安全删除文件,忽略 Windows 上的文件锁定错误。"""
204
+ try:
205
+ path.unlink(missing_ok=True)
206
+ except PermissionError:
207
+ logger.debug("临时文件被锁定,跳过删除: %s", path)
208
+
209
+
210
+ def _has_extractor(extension: str) -> bool:
211
+ """检查扩展名是否有注册提取器。"""
212
+ from fuscan.extractors import get_extractor
213
+
214
+ return get_extractor(extension) is not None
215
+
216
+
217
+ def _decode_bytes(data: bytes) -> str:
218
+ """字节流转字符串,优先 UTF-8 回退到 charset-normalizer。"""
219
+ try:
220
+ return data.decode("utf-8")
221
+ except UnicodeDecodeError:
222
+ pass
223
+ try:
224
+ from charset_normalizer import from_bytes
225
+ except ImportError:
226
+ return data.decode("utf-8", errors="ignore")
227
+ result = from_bytes(data).best()
228
+ return str(result) if result is not None else data.decode("utf-8", errors="ignore")
229
+
230
+
231
+ # 纯文本类扩展名(直接解码无需第三方库)
232
+ _TEXT_EXTENSIONS = {
233
+ "txt",
234
+ "md",
235
+ "rst",
236
+ "log",
237
+ "csv",
238
+ "tsv",
239
+ "json",
240
+ "yaml",
241
+ "yml",
242
+ "xml",
243
+ "html",
244
+ "htm",
245
+ "css",
246
+ "js",
247
+ "ts",
248
+ "py",
249
+ "java",
250
+ "c",
251
+ "h",
252
+ "cpp",
253
+ "hpp",
254
+ "cs",
255
+ "go",
256
+ "rs",
257
+ "rb",
258
+ "php",
259
+ "sh",
260
+ "bat",
261
+ "ps1",
262
+ "ini",
263
+ "cfg",
264
+ "conf",
265
+ "toml",
266
+ "properties",
267
+ "sql",
268
+ "lua",
269
+ "pl",
270
+ }
@@ -0,0 +1,96 @@
1
+ """ZIP 压缩文件读取器。
2
+
3
+ 基于标准库 zipfile 实现,支持加密压缩包(密码尝试)与损坏压缩包容错。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from fuscan.archive.base import ArchiveEntry, ArchiveError, ArchiveReader
13
+
14
+ __all__ = ["ZipReader"]
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ZipReader(ArchiveReader):
20
+ """ZIP 压缩包读取器。
21
+
22
+ 使用 zipfile.ZipFile 读取标准 ZIP 格式(含 zip/gzip 场景下的常规 zip)。
23
+ 加密条目需要密码;未提供密码或密码错误时跳过并记录。
24
+ """
25
+
26
+ def __init__(self, path: Path, password: str | None = None) -> None:
27
+ self._path = path
28
+ self._password = password.encode("utf-8") if password else None
29
+ try:
30
+ self._zip = zipfile.ZipFile(str(path), mode="r")
31
+ except zipfile.BadZipFile as exc:
32
+ raise ArchiveError(f"损坏的 ZIP 文件: {path}") from exc
33
+ except OSError as exc:
34
+ raise ArchiveError(f"无法打开 ZIP 文件: {path}: {exc}") from exc
35
+
36
+ @property
37
+ def supported_extensions(self) -> tuple[str, ...]:
38
+ return ("zip",)
39
+
40
+ def list_entries(self) -> list[ArchiveEntry]:
41
+ """列出压缩包内所有条目(目录与文件均列出)。"""
42
+ entries: list[ArchiveEntry] = []
43
+ for info in self._zip.infolist():
44
+ entries.append(
45
+ ArchiveEntry(
46
+ archive_path=self._path,
47
+ entry_name=info.filename,
48
+ size=info.file_size,
49
+ compressed_size=info.compress_size,
50
+ is_dir=info.is_dir(),
51
+ )
52
+ )
53
+ return entries
54
+
55
+ def read_entry(self, entry_name: str) -> bytes:
56
+ """读取条目内容。
57
+
58
+ :raises ArchiveError: 读取失败(加密、损坏、找不到条目等)
59
+ """
60
+ try:
61
+ info = self._zip.getinfo(entry_name)
62
+ except KeyError as exc:
63
+ raise ArchiveError(f"ZIP 条目不存在: {entry_name}") from exc
64
+
65
+ if info.is_dir():
66
+ return b""
67
+
68
+ # 加密条目:尝试密码;无密码则跳过
69
+ if info.flag_bits & 0x1:
70
+ if self._password is None:
71
+ logger.info("ZIP 条目加密且未提供密码,跳过: %s!%s", self._path, entry_name)
72
+ raise ArchiveError(f"加密条目未提供密码: {entry_name}")
73
+ try:
74
+ return self._zip.read(entry_name, pwd=self._password)
75
+ except RuntimeError as exc:
76
+ raise ArchiveError(f"ZIP 密码错误或解密失败: {entry_name}: {exc}") from exc
77
+
78
+ try:
79
+ return self._zip.read(entry_name)
80
+ except RuntimeError as exc:
81
+ raise ArchiveError(f"ZIP 条目读取失败: {entry_name}: {exc}") from exc
82
+ except zipfile.BadZipFile as exc:
83
+ raise ArchiveError(f"ZIP 条目损坏: {entry_name}: {exc}") from exc
84
+
85
+ def close(self) -> None:
86
+ """关闭 ZIP 文件句柄。"""
87
+ try:
88
+ self._zip.close()
89
+ except Exception: # pragma: no cover - 关闭异常无需上报
90
+ logger.debug("关闭 ZIP 文件句柄失败: %s", self._path, exc_info=True)
91
+
92
+ def __enter__(self) -> ZipReader:
93
+ return self
94
+
95
+ def __exit__(self, exc_type, exc, tb) -> None:
96
+ self.close()
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783758718020" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7191" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M853.333333 146.285714a73.142857 73.142857 0 0 1 73.142857 73.142857v463.238096a73.142857 73.142857 0 0 1-73.142857 73.142857H170.666667a73.142857 73.142857 0 0 1-73.142857-73.142857V219.428571a73.142857 73.142857 0 0 1 73.142857-73.142857h682.666666z m0 73.142857H170.666667v463.238096h682.666666V219.428571z m-170.666666 195.047619v73.142858H341.333333v-73.142858h341.333334zM341.333333 804.571429h341.333334v73.142857H341.333333z" p-id="7192"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783758702270" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6183" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M85.333333 682.666667v-51.498667c0-10.410667 1.493333-20.736 4.48-30.634667L205.098667 216.32A64 64 0 0 1 266.410667 170.666667h448.512a64 64 0 0 1 61.312 45.653333l115.285333 384.213333c2.986667 9.898667 4.48 20.224 4.48 30.634667V789.333333a64 64 0 0 1-64 64h-682.666667A64 64 0 0 1 85.333333 789.333333V682.666667z m64-85.333334h682.666667c4.992 0 9.813333 0.554667 14.506667 1.664l-111.146667-370.432A21.333333 21.333333 0 0 0 714.922667 213.333333H266.410667a21.333333 21.333333 0 0 0-20.48 15.232L134.826667 598.997333c4.693333-1.109333 9.514667-1.664 14.506666-1.664zM128 704v85.333333a21.333333 21.333333 0 0 0 21.333333 21.333334h682.666667a21.333333 21.333333 0 0 0 21.333333-21.333334v-128a21.333333 21.333333 0 0 0-21.333333-21.333333h-682.666667a21.333333 21.333333 0 0 0-21.333333 21.333333v42.666667zM234.666667 768a21.333333 21.333333 0 1 1 0-42.666667h512a21.333333 21.333333 0 1 1 0 42.666667h-512z" p-id="6184"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783758757676" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8217" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M453.315048 146.285714a73.142857 73.142857 0 0 1 71.411809 57.295238l3.535238 15.847619H828.952381a73.142857 73.142857 0 0 1 73.142857 73.142858v512a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857V219.428571a73.142857 73.142857 0 0 1 73.142857-73.142857h258.267429z m0 73.142857H195.047619v585.142858h633.904762V414.47619H496.688762l-43.373714-195.047619zM780.190476 658.285714v73.142857H243.809524v-73.142857h536.380952z m48.761905-365.714285H544.49981l10.849523 48.761904H828.952381v-48.761904z" p-id="8218"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783770932709" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11147" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M53.333333 618.666667a32 32 0 0 1 32-32h853.333334a32 32 0 0 1 32 32V896a32 32 0 0 1-32 32H85.333333a32 32 0 0 1-32-32v-277.333333z m64 32v213.333333h789.333334v-213.333333H117.333333z" p-id="11148"></path><path d="M757.333333 810.666667a53.333333 53.333333 0 1 0 0-106.666667 53.333333 53.333333 0 0 0 0 106.666667z" p-id="11149"></path><path d="M161.493333 100.053333a32 32 0 0 1 31.317334-25.386666H832.426667a32 32 0 0 1 31.36 25.472l106.24 512-62.72 13.013333L806.4 138.666667H218.794667L116.650667 625.237333l-62.634667-13.141333 107.52-512z" p-id="11150"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783758650327" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8497" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M876.8 709.12H738.56V591.36c0-15.36-10.24-25.6-25.6-25.6-15.36 0-25.6 10.24-25.6 25.6v143.36c0 15.36 10.24 25.6 25.6 25.6H876.8c15.36 0 25.6-10.24 25.6-25.6s-12.8-25.6-25.6-25.6zM705.28 463.36c-153.6 0-279.04 125.44-279.04 279.04 0 153.6 125.44 279.04 279.04 279.04 153.6 0 279.04-125.44 279.04-279.04 0-153.6-125.44-276.48-279.04-279.04z m0 506.88c-125.44 0-227.84-102.4-227.84-227.84s102.4-227.84 227.84-227.84 227.84 102.4 227.84 227.84c-2.56 128-102.4 227.84-227.84 227.84z m25.6-622.08c0-15.36-10.24-25.6-25.6-25.6h-473.6c-15.36 0-25.6 10.24-25.6 25.6 0 15.36 10.24 25.6 25.6 25.6h473.6c12.8 0 25.6-10.24 25.6-25.6z m-307.2 207.36c0-15.36-10.24-25.6-25.6-25.6h-166.4c-15.36 0-25.6 10.24-25.6 25.6s10.24 25.6 25.6 25.6h166.4c15.36 0 25.6-12.8 25.6-25.6z m-192 153.6c-15.36 0-25.6 10.24-25.6 25.6s10.24 25.6 25.6 25.6h128c15.36 0 25.6-10.24 25.6-25.6s-10.24-25.6-25.6-25.6h-128zM482.56 972.8H90.88V51.2h174.08v38.4c0 33.28 25.6 64 58.88 64 12.8 0 343.04 2.56 373.76 0 33.28 0 58.88-28.16 56.32-61.44v-38.4H928v437.76c0 15.36 10.24 25.6 25.6 25.6 15.36 0 25.6-10.24 25.6-25.6V28.16c0-15.36-10.24-25.6-25.6-25.6H728.32c-15.36 0-25.6 10.24-25.6 25.6v64c0 7.68-5.12 12.8-10.24 12.8H326.4c-7.68 0-12.8-5.12-12.8-12.8V25.6C313.6 10.24 303.36 0 288 0H65.28c-15.36 0-25.6 10.24-25.6 25.6v972.8c0 15.36 10.24 25.6 25.6 25.6h417.28c15.36 0 25.6-10.24 25.6-25.6 0-15.36-12.8-25.6-25.6-25.6zM395.52 51.2h230.4c15.36 0 25.6-10.24 25.6-25.6 0-15.36-10.24-25.6-25.6-25.6h-230.4c-15.36 0-25.6 10.24-25.6 25.6 0 15.36 12.8 25.6 25.6 25.6z" p-id="8498"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783758598259" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7503" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M256 232V152c0-17.674 14.326-32 32-32h704c17.674 0 32 14.326 32 32v80c0 17.674-14.326 32-32 32H288c-17.674 0-32-14.326-32-32z m32 352h704c17.674 0 32-14.326 32-32v-80c0-17.674-14.326-32-32-32H288c-17.674 0-32 14.326-32 32v80c0 17.674 14.326 32 32 32z m0 320h704c17.674 0 32-14.326 32-32v-80c0-17.674-14.326-32-32-32H288c-17.674 0-32 14.326-32 32v80c0 17.674 14.326 32 32 32zM32 288h128c17.674 0 32-14.326 32-32V128c0-17.674-14.326-32-32-32H32C14.326 96 0 110.326 0 128v128c0 17.674 14.326 32 32 32z m0 320h128c17.674 0 32-14.326 32-32v-128c0-17.674-14.326-32-32-32H32c-17.674 0-32 14.326-32 32v128c0 17.674 14.326 32 32 32z m0 320h128c17.674 0 32-14.326 32-32v-128c0-17.674-14.326-32-32-32H32c-17.674 0-32 14.326-32 32v128c0 17.674 14.326 32 32 32z" p-id="7504"></path></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783752383152" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3489" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M426.666667 288v448a32.426667 32.426667 0 0 1-32 32h-64a32.426667 32.426667 0 0 1-32-32V288A32.426667 32.426667 0 0 1 330.666667 256h64a32.426667 32.426667 0 0 1 32 32zM693.333333 256h-64a32.426667 32.426667 0 0 0-32 32v448a32.426667 32.426667 0 0 0 32 32h64a32.426667 32.426667 0 0 0 32-32V288a32.426667 32.426667 0 0 0-32-32z" p-id="3490"></path></svg>