whiff-python 0.1.0__cp311-abi3-win_amd64.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.
- whiff/__init__.py +64 -0
- whiff/_native.pyd +0 -0
- whiff/_native.pyi +11 -0
- whiff/engine.py +385 -0
- whiff/errors.py +153 -0
- whiff/input.py +65 -0
- whiff/limiter.py +123 -0
- whiff/merge.py +381 -0
- whiff/options.py +238 -0
- whiff/plugins.py +315 -0
- whiff/privacy.py +185 -0
- whiff/py.typed +0 -0
- whiff/report.py +96 -0
- whiff/schemas/whiff-1.schema.json +1735 -0
- whiff/scratch.py +294 -0
- whiff_python-0.1.0.dist-info/METADATA +183 -0
- whiff_python-0.1.0.dist-info/RECORD +20 -0
- whiff_python-0.1.0.dist-info/WHEEL +4 -0
- whiff_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- whiff_python-0.1.0.dist-info/sboms/whiff_native.cyclonedx.json +5681 -0
whiff/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Identify a file or bytes and return a whiff/1 report.
|
|
2
|
+
|
|
3
|
+
The report is the JSON document produced by the Rust `whiff` crate: what the
|
|
4
|
+
file is, where it came from, which rights apply, and a compact view of its
|
|
5
|
+
content. This package wraps that crate. It does not reimplement format
|
|
6
|
+
extractors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from whiff.engine import (
|
|
10
|
+
Engine,
|
|
11
|
+
__version__,
|
|
12
|
+
configure,
|
|
13
|
+
extract,
|
|
14
|
+
extract_many,
|
|
15
|
+
llm,
|
|
16
|
+
schema,
|
|
17
|
+
version,
|
|
18
|
+
)
|
|
19
|
+
from whiff.errors import (
|
|
20
|
+
AccessError,
|
|
21
|
+
AnalyzerError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
ExtractorError,
|
|
24
|
+
InvalidOptionError,
|
|
25
|
+
InvalidReportError,
|
|
26
|
+
NativeError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
OverloadedError,
|
|
29
|
+
UnsupportedInputError,
|
|
30
|
+
WhiffError,
|
|
31
|
+
WhiffTimeoutError,
|
|
32
|
+
)
|
|
33
|
+
from whiff.input import Input
|
|
34
|
+
from whiff.merge import merge
|
|
35
|
+
from whiff.plugins import Analyzer, Extractor
|
|
36
|
+
from whiff.report import native_covered
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AccessError",
|
|
40
|
+
"Analyzer",
|
|
41
|
+
"AnalyzerError",
|
|
42
|
+
"ConflictError",
|
|
43
|
+
"Engine",
|
|
44
|
+
"Extractor",
|
|
45
|
+
"ExtractorError",
|
|
46
|
+
"Input",
|
|
47
|
+
"InvalidOptionError",
|
|
48
|
+
"InvalidReportError",
|
|
49
|
+
"NativeError",
|
|
50
|
+
"NotFoundError",
|
|
51
|
+
"OverloadedError",
|
|
52
|
+
"UnsupportedInputError",
|
|
53
|
+
"WhiffError",
|
|
54
|
+
"WhiffTimeoutError",
|
|
55
|
+
"__version__",
|
|
56
|
+
"configure",
|
|
57
|
+
"extract",
|
|
58
|
+
"extract_many",
|
|
59
|
+
"llm",
|
|
60
|
+
"merge",
|
|
61
|
+
"native_covered",
|
|
62
|
+
"schema",
|
|
63
|
+
"version",
|
|
64
|
+
]
|
whiff/_native.pyd
ADDED
|
Binary file
|
whiff/_native.pyi
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class NativeFault(Exception): ...
|
|
2
|
+
|
|
3
|
+
class Engine:
|
|
4
|
+
def __init__(self, options_json: str) -> None: ...
|
|
5
|
+
def extract_path(self, path: bytes) -> bytes: ...
|
|
6
|
+
def extract_bytes(self, data: bytes, name: str | None) -> bytes: ...
|
|
7
|
+
def llm_path(self, path: bytes, tokens: int) -> bytes: ...
|
|
8
|
+
def llm_bytes(self, data: bytes, name: str | None, tokens: int) -> bytes: ...
|
|
9
|
+
|
|
10
|
+
def version() -> bytes: ...
|
|
11
|
+
def default_limits(profile: str | None) -> bytes: ...
|
whiff/engine.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Public extraction entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from concurrent.futures import TimeoutError as FuturesTimeout
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from whiff import input as input_mod
|
|
13
|
+
from whiff import plugins, scratch
|
|
14
|
+
from whiff.errors import (
|
|
15
|
+
NativeError,
|
|
16
|
+
UnsupportedInputError,
|
|
17
|
+
WhiffError,
|
|
18
|
+
WhiffTimeoutError,
|
|
19
|
+
error_for,
|
|
20
|
+
)
|
|
21
|
+
from whiff.limiter import LIMITER
|
|
22
|
+
from whiff.options import native_payload, validate
|
|
23
|
+
from whiff.report import SCHEMA, decode
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
_NATIVE_KEYS = {
|
|
28
|
+
"profile",
|
|
29
|
+
"hashes",
|
|
30
|
+
"privacy",
|
|
31
|
+
"only",
|
|
32
|
+
"skip",
|
|
33
|
+
"format",
|
|
34
|
+
"provenance",
|
|
35
|
+
"limits",
|
|
36
|
+
}
|
|
37
|
+
_BATCH_TIMEOUT_MS = 300_000
|
|
38
|
+
_schema_cache: dict[str, Any] | None = None
|
|
39
|
+
_default_engine: Engine | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extension():
|
|
43
|
+
try:
|
|
44
|
+
from whiff import _native
|
|
45
|
+
except ImportError as err:
|
|
46
|
+
raise NativeError(
|
|
47
|
+
"whiff native extension is not built; run `maturin develop` from the repository root"
|
|
48
|
+
) from err
|
|
49
|
+
return _native
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _from_native(exc: BaseException, path: str | None) -> WhiffError:
|
|
53
|
+
args = getattr(exc, "args", ())
|
|
54
|
+
if len(args) == 1 and isinstance(args[0], tuple) and len(args[0]) == 2:
|
|
55
|
+
args = args[0]
|
|
56
|
+
if len(args) >= 2 and isinstance(args[0], str) and isinstance(args[1], str):
|
|
57
|
+
err = error_for(args[0], args[1], path=path)
|
|
58
|
+
else:
|
|
59
|
+
err = NativeError(str(exc), path=path)
|
|
60
|
+
if path is not None and err.path is None:
|
|
61
|
+
err.path = path
|
|
62
|
+
err.args = (err._format(),)
|
|
63
|
+
return err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Engine:
|
|
67
|
+
"""Reusable extractor. Safe to share across threads.
|
|
68
|
+
|
|
69
|
+
Keyword arguments are the native options (``profile``, ``hashes``,
|
|
70
|
+
``privacy``, ``only``, ``skip``, ``format``, ``provenance``, ``limits``).
|
|
71
|
+
Per-call keywords override them.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
75
|
+
unknown = sorted(key for key in kwargs if key not in _NATIVE_KEYS)
|
|
76
|
+
if unknown:
|
|
77
|
+
from whiff.errors import InvalidOptionError
|
|
78
|
+
|
|
79
|
+
raise InvalidOptionError(f"unknown option(s): {unknown}", details={"unknown": unknown})
|
|
80
|
+
normalized = validate(kwargs, "path")
|
|
81
|
+
self._native_kwargs = {key: normalized[key] for key in kwargs}
|
|
82
|
+
self._cache_key: str | None = None
|
|
83
|
+
self._rust: Any = None
|
|
84
|
+
|
|
85
|
+
def extract(self, source: Any, **kwargs: Any) -> dict[str, Any]:
|
|
86
|
+
combined = {**self._native_kwargs, **kwargs}
|
|
87
|
+
if _is_bytes(source) or _is_file(source):
|
|
88
|
+
opts = validate(combined, "bytes")
|
|
89
|
+
return _extract_bytes(source, opts, self)
|
|
90
|
+
if _is_path(source):
|
|
91
|
+
opts = validate(combined, "path")
|
|
92
|
+
return _extract_path(source, opts, self)
|
|
93
|
+
raise UnsupportedInputError("path must be a path, bytes, or a binary file object")
|
|
94
|
+
|
|
95
|
+
def llm(self, source: Any, **kwargs: Any) -> dict[str, Any]:
|
|
96
|
+
combined = {**self._native_kwargs, **kwargs}
|
|
97
|
+
if _is_bytes(source) or _is_file(source):
|
|
98
|
+
opts = validate(combined, "llm_bytes")
|
|
99
|
+
return _llm_bytes(source, opts, self)
|
|
100
|
+
if _is_path(source):
|
|
101
|
+
opts = validate(combined, "llm_path")
|
|
102
|
+
return _llm_path(source, opts, self)
|
|
103
|
+
raise UnsupportedInputError("path must be a path, bytes, or a binary file object")
|
|
104
|
+
|
|
105
|
+
def extract_many(self, paths: Sequence[Any], **kwargs: Any) -> list[tuple[str, Any]]:
|
|
106
|
+
combined = {**self._native_kwargs, **kwargs}
|
|
107
|
+
return _extract_many(paths, combined, self)
|
|
108
|
+
|
|
109
|
+
def _rust_engine(self, opts: dict[str, Any]) -> Any:
|
|
110
|
+
payload = json.dumps(
|
|
111
|
+
native_payload(opts, include_tokens=False),
|
|
112
|
+
sort_keys=True,
|
|
113
|
+
separators=(",", ":"),
|
|
114
|
+
)
|
|
115
|
+
if payload != self._cache_key:
|
|
116
|
+
self._rust = _extension().Engine(payload)
|
|
117
|
+
self._cache_key = payload
|
|
118
|
+
return self._rust
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract(source: Any, **kwargs: Any) -> dict[str, Any]:
|
|
122
|
+
"""Extract a ``whiff/1`` report from a path, ``bytes``, or a binary file.
|
|
123
|
+
|
|
124
|
+
Problems inside a readable file are recorded in ``diagnostics`` and still
|
|
125
|
+
return a report. The call raises :class:`~whiff.errors.WhiffError` when
|
|
126
|
+
the path cannot be inspected or an option is invalid.
|
|
127
|
+
"""
|
|
128
|
+
return _engine_for(kwargs).extract(source, **_without_native(kwargs))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def llm(source: Any, **kwargs: Any) -> dict[str, Any]:
|
|
132
|
+
"""Compact ``Report::llm_view`` projection. Python plugins do not run."""
|
|
133
|
+
return _engine_for(kwargs).llm(source, **_without_native(kwargs))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_many(paths: Sequence[Any], **kwargs: Any) -> list[tuple[str, Any]]:
|
|
137
|
+
"""Extract each path, in input order.
|
|
138
|
+
|
|
139
|
+
``on_error="collect"`` (default) returns ``(path, report)`` or
|
|
140
|
+
``(path, error)``. ``on_error="halt"`` raises the first error and sets
|
|
141
|
+
``details["partial"]`` to the pairs completed before it. ``timeout`` is
|
|
142
|
+
per path, in milliseconds (default 300000). Native work already running
|
|
143
|
+
is not cancelled when a path times out.
|
|
144
|
+
"""
|
|
145
|
+
native = {key: value for key, value in kwargs.items() if key in _NATIVE_KEYS}
|
|
146
|
+
rest = {key: value for key, value in kwargs.items() if key not in _NATIVE_KEYS}
|
|
147
|
+
engine = Engine(**native) if native else _default()
|
|
148
|
+
return engine.extract_many(paths, **rest)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def schema() -> dict[str, Any]:
|
|
152
|
+
"""Vendored ``whiff/1`` JSON Schema."""
|
|
153
|
+
global _schema_cache
|
|
154
|
+
if _schema_cache is None:
|
|
155
|
+
from whiff.merge import _SCHEMA_PATH
|
|
156
|
+
|
|
157
|
+
_schema_cache = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
158
|
+
return _schema_cache
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def version() -> dict[str, Any]:
|
|
162
|
+
"""Binding version, upstream crate, schema id, features, and limit caps."""
|
|
163
|
+
info = json.loads(bytes(_extension().version()))
|
|
164
|
+
info["version"] = __version__
|
|
165
|
+
info["schema"] = info.get("schema") or SCHEMA
|
|
166
|
+
return info
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def configure(
|
|
170
|
+
*,
|
|
171
|
+
native_max_concurrency: int | None = None,
|
|
172
|
+
native_wait_ms: int | None = None,
|
|
173
|
+
scratch_dir: str | None = None,
|
|
174
|
+
scratch_max_bytes: int | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Set process-wide native concurrency and scratch-staging limits."""
|
|
177
|
+
if native_max_concurrency is not None or native_wait_ms is not None:
|
|
178
|
+
LIMITER.configure(max_concurrency=native_max_concurrency, wait_ms=native_wait_ms)
|
|
179
|
+
if scratch_dir is not None or scratch_max_bytes is not None:
|
|
180
|
+
scratch.configure(directory=scratch_dir, max_bytes=scratch_max_bytes)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _default() -> Engine:
|
|
184
|
+
global _default_engine
|
|
185
|
+
if _default_engine is None:
|
|
186
|
+
_default_engine = Engine()
|
|
187
|
+
return _default_engine
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _engine_for(kwargs: dict[str, Any]) -> Engine:
|
|
191
|
+
native = {key: value for key, value in kwargs.items() if key in _NATIVE_KEYS}
|
|
192
|
+
if native:
|
|
193
|
+
return Engine(**native)
|
|
194
|
+
return _default()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _without_native(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
198
|
+
return {key: value for key, value in kwargs.items() if key not in _NATIVE_KEYS}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_path(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
|
|
202
|
+
path = _display_path(source)
|
|
203
|
+
tag = scratch.stage(path, opts.get("stage", "never"))
|
|
204
|
+
try:
|
|
205
|
+
staged = tag.staged if tag is not None else path
|
|
206
|
+
try:
|
|
207
|
+
with LIMITER.slot():
|
|
208
|
+
raw = engine._rust_engine(opts).extract_path(os.fsencode(staged))
|
|
209
|
+
except WhiffError as err:
|
|
210
|
+
raise scratch.restore_error(err, tag) from None
|
|
211
|
+
except Exception as err:
|
|
212
|
+
if _is_native_fault(err):
|
|
213
|
+
raise scratch.restore_error(_from_native(err, path), tag) from None
|
|
214
|
+
raise
|
|
215
|
+
report = scratch.restore_report(decode(bytes(raw)), tag)
|
|
216
|
+
finally:
|
|
217
|
+
scratch.cleanup(tag)
|
|
218
|
+
return plugins.run(input_mod.from_path(path), report, opts)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_bytes(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
|
|
222
|
+
data, name = _bytes_of(source, opts)
|
|
223
|
+
mime = opts.get("mime")
|
|
224
|
+
try:
|
|
225
|
+
with LIMITER.slot():
|
|
226
|
+
raw = engine._rust_engine(opts).extract_bytes(data, name)
|
|
227
|
+
except WhiffError:
|
|
228
|
+
raise
|
|
229
|
+
except Exception as err:
|
|
230
|
+
if _is_native_fault(err):
|
|
231
|
+
raise _from_native(err, name) from None
|
|
232
|
+
raise
|
|
233
|
+
report = decode(bytes(raw))
|
|
234
|
+
return plugins.run(input_mod.from_binary(data, name=name, mime=mime), report, opts)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _llm_path(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
|
|
238
|
+
path = _display_path(source)
|
|
239
|
+
tag = scratch.stage(path, opts.get("stage", "never"))
|
|
240
|
+
try:
|
|
241
|
+
staged = tag.staged if tag is not None else path
|
|
242
|
+
try:
|
|
243
|
+
with LIMITER.slot():
|
|
244
|
+
raw = engine._rust_engine(opts).llm_path(
|
|
245
|
+
os.fsencode(staged), opts.get("tokens", 800)
|
|
246
|
+
)
|
|
247
|
+
except WhiffError as err:
|
|
248
|
+
raise scratch.restore_error(err, tag) from None
|
|
249
|
+
except Exception as err:
|
|
250
|
+
if _is_native_fault(err):
|
|
251
|
+
raise scratch.restore_error(_from_native(err, path), tag) from None
|
|
252
|
+
raise
|
|
253
|
+
return scratch.restore_report(decode(bytes(raw)), tag)
|
|
254
|
+
finally:
|
|
255
|
+
scratch.cleanup(tag)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _llm_bytes(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
|
|
259
|
+
data, name = _bytes_of(source, opts)
|
|
260
|
+
try:
|
|
261
|
+
with LIMITER.slot():
|
|
262
|
+
raw = engine._rust_engine(opts).llm_bytes(data, name, opts.get("tokens", 800))
|
|
263
|
+
except WhiffError:
|
|
264
|
+
raise
|
|
265
|
+
except Exception as err:
|
|
266
|
+
if _is_native_fault(err):
|
|
267
|
+
raise _from_native(err, name) from None
|
|
268
|
+
raise
|
|
269
|
+
return decode(bytes(raw))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _extract_many(
|
|
273
|
+
paths: Sequence[Any], opts_in: dict[str, Any], engine: Engine
|
|
274
|
+
) -> list[tuple[str, Any]]:
|
|
275
|
+
if isinstance(paths, (str, bytes)) or not isinstance(paths, Sequence):
|
|
276
|
+
raise UnsupportedInputError("paths must be a list")
|
|
277
|
+
opts = validate(dict(opts_in), "extract_many")
|
|
278
|
+
timeout_ms = opts.get("timeout", _BATCH_TIMEOUT_MS)
|
|
279
|
+
timeout_s = None if timeout_ms is None else timeout_ms / 1000
|
|
280
|
+
workers = opts.get("max_concurrency", 4)
|
|
281
|
+
on_error = opts.get("on_error", "collect")
|
|
282
|
+
results: list[tuple[str, Any]] = []
|
|
283
|
+
|
|
284
|
+
def one(path: Any) -> Any:
|
|
285
|
+
try:
|
|
286
|
+
if not _is_path(path):
|
|
287
|
+
raise UnsupportedInputError("path must be a path")
|
|
288
|
+
single = {
|
|
289
|
+
key: value
|
|
290
|
+
for key, value in opts.items()
|
|
291
|
+
if key not in {"max_concurrency", "on_error", "timeout"}
|
|
292
|
+
}
|
|
293
|
+
return _extract_path(path, single, engine)
|
|
294
|
+
except WhiffError as err:
|
|
295
|
+
return err
|
|
296
|
+
|
|
297
|
+
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="whiff") as pool:
|
|
298
|
+
futures = [pool.submit(one, path) for path in paths]
|
|
299
|
+
for path, future in zip(paths, futures, strict=True):
|
|
300
|
+
label = _display_path(path) if _is_path(path) else str(path)
|
|
301
|
+
try:
|
|
302
|
+
item = future.result(timeout=timeout_s)
|
|
303
|
+
except FuturesTimeout:
|
|
304
|
+
item = WhiffTimeoutError(
|
|
305
|
+
f"extraction exceeded {timeout_ms}ms",
|
|
306
|
+
path=label if _is_path(path) else None,
|
|
307
|
+
)
|
|
308
|
+
if isinstance(item, WhiffError):
|
|
309
|
+
if on_error == "halt":
|
|
310
|
+
item.details = {**item.details, "partial": list(results)}
|
|
311
|
+
if item.path is None and _is_path(path):
|
|
312
|
+
item.path = label
|
|
313
|
+
item.args = (item._format(),)
|
|
314
|
+
raise item
|
|
315
|
+
results.append((label, item))
|
|
316
|
+
return results
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _bytes_of(source: Any, opts: dict[str, Any]) -> tuple[bytes, str | None]:
|
|
320
|
+
name = opts.get("name") or opts.get("filename")
|
|
321
|
+
if _is_file(source):
|
|
322
|
+
if name is None:
|
|
323
|
+
raw_name = getattr(source, "name", None)
|
|
324
|
+
if isinstance(raw_name, str) and raw_name not in {"<stdin>", "<stdout>"}:
|
|
325
|
+
name = os.path.basename(raw_name) or None
|
|
326
|
+
cap = _read_cap(opts)
|
|
327
|
+
return _read_file(source, cap), name
|
|
328
|
+
data = _as_bytes(source)
|
|
329
|
+
if data is None:
|
|
330
|
+
raise UnsupportedInputError("expected bytes or a binary file object")
|
|
331
|
+
return data, name
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _read_cap(opts: dict[str, Any]) -> int:
|
|
335
|
+
limits = opts.get("limits") or {}
|
|
336
|
+
if "max_read_bytes" in limits:
|
|
337
|
+
return int(limits["max_read_bytes"])
|
|
338
|
+
raw = _extension().default_limits(opts.get("profile"))
|
|
339
|
+
return int(json.loads(bytes(raw))["max_read_bytes"])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _read_file(source: Any, cap: int) -> bytes:
|
|
343
|
+
chunks: list[bytes] = []
|
|
344
|
+
remaining = cap
|
|
345
|
+
while remaining > 0:
|
|
346
|
+
chunk = source.read(remaining)
|
|
347
|
+
if not chunk:
|
|
348
|
+
break
|
|
349
|
+
if isinstance(chunk, str):
|
|
350
|
+
raise UnsupportedInputError("file object must be opened in binary mode")
|
|
351
|
+
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
352
|
+
raise UnsupportedInputError("file object must be opened in binary mode")
|
|
353
|
+
piece = bytes(chunk)
|
|
354
|
+
chunks.append(piece)
|
|
355
|
+
remaining -= len(piece)
|
|
356
|
+
return b"".join(chunks)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _is_native_fault(err: BaseException) -> bool:
|
|
360
|
+
return type(err).__name__ == "NativeFault"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _is_path(source: Any) -> bool:
|
|
364
|
+
return isinstance(source, (str, os.PathLike)) and not isinstance(source, bytes)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_bytes(source: Any) -> bool:
|
|
368
|
+
return isinstance(source, (bytes, bytearray, memoryview))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _is_file(source: Any) -> bool:
|
|
372
|
+
return not _is_bytes(source) and not _is_path(source) and hasattr(source, "read")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _display_path(source: Any) -> str:
|
|
376
|
+
text = os.fspath(source)
|
|
377
|
+
if isinstance(text, bytes):
|
|
378
|
+
return os.fsdecode(text)
|
|
379
|
+
return text
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _as_bytes(source: Any) -> bytes | None:
|
|
383
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
384
|
+
return bytes(source)
|
|
385
|
+
return None
|
whiff/errors.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Public exceptions.
|
|
2
|
+
|
|
3
|
+
IO failures subclass both :class:`WhiffError` and the matching builtin
|
|
4
|
+
(``FileNotFoundError`` or ``PermissionError``) so either handler catches them.
|
|
5
|
+
Messages for IO errors include the full path. Log the base name when paths
|
|
6
|
+
are sensitive.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_SENSITIVE = {"bytes", "content", "binary"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scrub(value: object) -> object:
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {key: _scrub(item) for key, item in value.items() if key not in _SENSITIVE}
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return [_scrub(item) for item in value]
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WhiffError(Exception):
|
|
23
|
+
"""Failure of a whole call. Problems inside a readable file are not errors.
|
|
24
|
+
|
|
25
|
+
``reason`` is a stable string (``enoent``, ``invalid_option``, …).
|
|
26
|
+
``details`` drops keys named ``bytes``, ``content``, or ``binary``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
reason: str,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
path: str | None = None,
|
|
35
|
+
details: dict | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.reason = reason
|
|
38
|
+
self.message = message
|
|
39
|
+
self.path = path
|
|
40
|
+
scrubbed = _scrub(details or {})
|
|
41
|
+
self.details: dict = scrubbed if isinstance(scrubbed, dict) else {}
|
|
42
|
+
super().__init__(self._format())
|
|
43
|
+
|
|
44
|
+
def _format(self) -> str:
|
|
45
|
+
base = f"{self.reason}: {self.message}"
|
|
46
|
+
if self.path:
|
|
47
|
+
return f"{base} (path: {self.path})"
|
|
48
|
+
return base
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFoundError(WhiffError, FileNotFoundError):
|
|
52
|
+
def __init__(
|
|
53
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__("enoent", message, path=path, details=details)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AccessError(WhiffError, PermissionError):
|
|
59
|
+
def __init__(
|
|
60
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__("eacces", message, path=path, details=details)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvalidOptionError(WhiffError):
|
|
66
|
+
def __init__(
|
|
67
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__("invalid_option", message, path=path, details=details)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UnsupportedInputError(WhiffError):
|
|
73
|
+
def __init__(
|
|
74
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__("unsupported_input", message, path=path, details=details)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NativeError(WhiffError):
|
|
80
|
+
def __init__(
|
|
81
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__("native_error", message, path=path, details=details)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OverloadedError(WhiffError):
|
|
87
|
+
def __init__(
|
|
88
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__("overloaded", message, path=path, details=details)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InvalidReportError(WhiffError):
|
|
94
|
+
def __init__(
|
|
95
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
96
|
+
) -> None:
|
|
97
|
+
super().__init__("invalid_report", message, path=path, details=details)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ExtractorError(WhiffError):
|
|
101
|
+
def __init__(
|
|
102
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__("extractor_error", message, path=path, details=details)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AnalyzerError(WhiffError):
|
|
108
|
+
def __init__(
|
|
109
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
110
|
+
) -> None:
|
|
111
|
+
super().__init__("analyzer_error", message, path=path, details=details)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WhiffTimeoutError(WhiffError):
|
|
115
|
+
def __init__(
|
|
116
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__("timeout", message, path=path, details=details)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ConflictError(WhiffError):
|
|
122
|
+
def __init__(
|
|
123
|
+
self, message: str, *, path: str | None = None, details: dict | None = None
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__("conflict", message, path=path, details=details)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_BY_REASON: dict[str, type[WhiffError]] = {
|
|
129
|
+
"enoent": NotFoundError,
|
|
130
|
+
"eacces": AccessError,
|
|
131
|
+
"invalid_option": InvalidOptionError,
|
|
132
|
+
"unsupported_input": UnsupportedInputError,
|
|
133
|
+
"native_error": NativeError,
|
|
134
|
+
"overloaded": OverloadedError,
|
|
135
|
+
"invalid_report": InvalidReportError,
|
|
136
|
+
"extractor_error": ExtractorError,
|
|
137
|
+
"analyzer_error": AnalyzerError,
|
|
138
|
+
"timeout": WhiffTimeoutError,
|
|
139
|
+
"conflict": ConflictError,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def error_for(
|
|
144
|
+
reason: str,
|
|
145
|
+
message: str,
|
|
146
|
+
*,
|
|
147
|
+
path: str | None = None,
|
|
148
|
+
details: dict | None = None,
|
|
149
|
+
) -> WhiffError:
|
|
150
|
+
cls = _BY_REASON.get(reason, NativeError)
|
|
151
|
+
if cls is NativeError and reason != "native_error":
|
|
152
|
+
return WhiffError(reason, message, path=path, details=details)
|
|
153
|
+
return cls(message, path=path, details=details)
|
whiff/input.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Description of one extraction input passed to Python plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Input:
|
|
12
|
+
"""Path or in-memory payload. ``bytes`` is omitted from :meth:`present`."""
|
|
13
|
+
|
|
14
|
+
source: str
|
|
15
|
+
path: str | None = None
|
|
16
|
+
data: bytes | None = None
|
|
17
|
+
filename: str | None = None
|
|
18
|
+
extension: str | None = None
|
|
19
|
+
mime: str | None = None
|
|
20
|
+
size: int | None = None
|
|
21
|
+
|
|
22
|
+
def present(self) -> dict[str, object]:
|
|
23
|
+
return {
|
|
24
|
+
"source": self.source,
|
|
25
|
+
"path": self.path,
|
|
26
|
+
"filename": self.filename,
|
|
27
|
+
"extension": self.extension,
|
|
28
|
+
"mime": self.mime,
|
|
29
|
+
"size": self.size,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def from_path(path: str) -> Input:
|
|
34
|
+
filename = os.path.basename(path)
|
|
35
|
+
size: int | None
|
|
36
|
+
try:
|
|
37
|
+
size = os.stat(path).st_size
|
|
38
|
+
except OSError:
|
|
39
|
+
size = None
|
|
40
|
+
return Input(
|
|
41
|
+
source="path",
|
|
42
|
+
path=path,
|
|
43
|
+
filename=filename,
|
|
44
|
+
extension=_extension(filename),
|
|
45
|
+
size=size,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_binary(data: bytes, *, name: str | None = None, mime: str | None = None) -> Input:
|
|
50
|
+
filename = name or "input.bin"
|
|
51
|
+
return Input(
|
|
52
|
+
source="binary",
|
|
53
|
+
data=data,
|
|
54
|
+
filename=filename,
|
|
55
|
+
extension=_extension(filename),
|
|
56
|
+
mime=mime,
|
|
57
|
+
size=len(data),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extension(filename: str) -> str | None:
|
|
62
|
+
ext = Path(filename).suffix
|
|
63
|
+
if not ext:
|
|
64
|
+
return None
|
|
65
|
+
return ext.lower()
|