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.
- fuscan/__init__.py +10 -0
- fuscan/__main__.py +8 -0
- fuscan/archive/__init__.py +46 -0
- fuscan/archive/base.py +102 -0
- fuscan/archive/rar_reader.py +111 -0
- fuscan/archive/scanner.py +270 -0
- fuscan/archive/zip_reader.py +96 -0
- fuscan/assets/icons/all_disk.svg +1 -0
- fuscan/assets/icons/disk.svg +1 -0
- fuscan/assets/icons/folder.svg +1 -0
- fuscan/assets/icons/hard_disk.svg +1 -0
- fuscan/assets/icons/history.svg +1 -0
- fuscan/assets/icons/load_list.svg +1 -0
- fuscan/assets/icons/pause.svg +1 -0
- fuscan/assets/icons/rescan.svg +1 -0
- fuscan/assets/icons/right.svg +1 -0
- fuscan/assets/icons/scan.svg +1 -0
- fuscan/assets/icons/stop.svg +1 -0
- fuscan/builtin/__init__.py +60 -0
- fuscan/builtin/rules.yaml +166 -0
- fuscan/cli.py +360 -0
- fuscan/config.py +95 -0
- fuscan/extractors/__init__.py +52 -0
- fuscan/extractors/base.py +106 -0
- fuscan/extractors/odf.py +48 -0
- fuscan/extractors/office.py +106 -0
- fuscan/extractors/pdf.py +59 -0
- fuscan/extractors/registry.py +36 -0
- fuscan/extractors/spreadsheet.py +112 -0
- fuscan/extractors/text.py +131 -0
- fuscan/extractors/wps.py +161 -0
- fuscan/gui/__init__.py +24 -0
- fuscan/gui/app.py +65 -0
- fuscan/gui/detail_dialog.py +292 -0
- fuscan/gui/detail_dialog.ui +175 -0
- fuscan/gui/detail_dialog_ui.py +143 -0
- fuscan/gui/main_window.py +1415 -0
- fuscan/gui/main_window.ui +981 -0
- fuscan/gui/main_window_ui.py +666 -0
- fuscan/gui/rule_editor.py +144 -0
- fuscan/gui/rule_editor.ui +116 -0
- fuscan/gui/rule_editor_ui.py +108 -0
- fuscan/gui/styles.qss +706 -0
- fuscan/gui/worker.py +156 -0
- fuscan/py.typed +0 -0
- fuscan/rules/__init__.py +51 -0
- fuscan/rules/errors.py +17 -0
- fuscan/rules/merge.py +63 -0
- fuscan/rules/model.py +128 -0
- fuscan/rules/parser.py +204 -0
- fuscan/scanner/__init__.py +64 -0
- fuscan/scanner/context.py +93 -0
- fuscan/scanner/matchers.py +224 -0
- fuscan/scanner/result.py +93 -0
- fuscan/scanner/scanner.py +369 -0
- fuscan/scanner/walker.py +118 -0
- fuscan/watcher/__init__.py +34 -0
- fuscan/watcher/ignore_dirs.py +80 -0
- fuscan/watcher/incremental.py +210 -0
- fuscan/watcher/monitor.py +243 -0
- fuscan/watcher/tray.py +287 -0
- fuscan-0.1.1.dist-info/METADATA +191 -0
- fuscan-0.1.1.dist-info/RECORD +66 -0
- fuscan-0.1.1.dist-info/WHEEL +4 -0
- fuscan-0.1.1.dist-info/entry_points.txt +2 -0
- fuscan-0.1.1.dist-info/licenses/LICENSE +21 -0
fuscan/__init__.py
ADDED
fuscan/__main__.py
ADDED
|
@@ -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>
|