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/options.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Validate public options before any native call.
|
|
2
|
+
|
|
3
|
+
Each entry point has its own allow-list, so an option that a function would
|
|
4
|
+
ignore is rejected instead of being dropped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from whiff.errors import InvalidOptionError
|
|
12
|
+
|
|
13
|
+
# Mirrors LIMIT_CAPS in native/src/lib.rs. A test compares both.
|
|
14
|
+
LIMIT_CAPS: dict[str, int] = {
|
|
15
|
+
"max_read_bytes": 4 * 1024 * 1024 * 1024,
|
|
16
|
+
"max_decompressed_bytes": 4 * 1024 * 1024 * 1024,
|
|
17
|
+
"max_depth": 8,
|
|
18
|
+
"max_children": 500,
|
|
19
|
+
"file_timeout_ms": 600_000,
|
|
20
|
+
"extractor_timeout_ms": 600_000,
|
|
21
|
+
"max_text_bytes": 64 * 1024 * 1024,
|
|
22
|
+
"hash_max_bytes": 4 * 1024 * 1024 * 1024,
|
|
23
|
+
"max_parse_bytes": 256 * 1024 * 1024,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_NONZERO = {
|
|
27
|
+
"max_read_bytes",
|
|
28
|
+
"max_decompressed_bytes",
|
|
29
|
+
"file_timeout_ms",
|
|
30
|
+
"extractor_timeout_ms",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_NATIVE = {
|
|
34
|
+
"profile",
|
|
35
|
+
"hashes",
|
|
36
|
+
"privacy",
|
|
37
|
+
"only",
|
|
38
|
+
"skip",
|
|
39
|
+
"format",
|
|
40
|
+
"provenance",
|
|
41
|
+
"limits",
|
|
42
|
+
}
|
|
43
|
+
_PIPELINE = {
|
|
44
|
+
"extractors",
|
|
45
|
+
"analyzers",
|
|
46
|
+
"mode",
|
|
47
|
+
"strict",
|
|
48
|
+
"plugin_timeout_ms",
|
|
49
|
+
"override_fields",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_ALLOWED = {
|
|
53
|
+
"path": _NATIVE | _PIPELINE | {"stage"},
|
|
54
|
+
"bytes": _NATIVE | _PIPELINE | {"name", "filename", "mime"},
|
|
55
|
+
"llm_path": _NATIVE | {"tokens", "stage"},
|
|
56
|
+
"llm_bytes": _NATIVE | {"tokens", "name", "filename"},
|
|
57
|
+
"extract_many": _NATIVE | _PIPELINE | {"stage", "max_concurrency", "on_error", "timeout"},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_PROFILES = ("minimal", "standard", "full", "llm")
|
|
61
|
+
_PRIVACY = ("strict", "off")
|
|
62
|
+
_MODES = ("fallback", "augment", "override")
|
|
63
|
+
_STAGES = ("never", "local")
|
|
64
|
+
_HASHES = ("sha256", "blake3")
|
|
65
|
+
|
|
66
|
+
_DEFAULTS = {
|
|
67
|
+
"profile": "standard",
|
|
68
|
+
"privacy": "strict",
|
|
69
|
+
"mode": "fallback",
|
|
70
|
+
"strict": False,
|
|
71
|
+
"plugin_timeout_ms": 5_000,
|
|
72
|
+
"provenance": False,
|
|
73
|
+
"tokens": 800,
|
|
74
|
+
"stage": "never",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate(opts: dict[str, Any], entry: str) -> dict[str, Any]:
|
|
79
|
+
"""Return normalized options for ``entry`` or raise :class:`InvalidOptionError`."""
|
|
80
|
+
allowed = _ALLOWED[entry]
|
|
81
|
+
unknown = sorted(key for key in opts if key not in allowed)
|
|
82
|
+
if unknown:
|
|
83
|
+
raise InvalidOptionError(
|
|
84
|
+
f"unknown option(s): {unknown}",
|
|
85
|
+
details={"unknown": unknown},
|
|
86
|
+
)
|
|
87
|
+
if "name" in opts and "filename" in opts:
|
|
88
|
+
raise InvalidOptionError("pass either name or filename, not both")
|
|
89
|
+
|
|
90
|
+
normalized = dict(_DEFAULTS)
|
|
91
|
+
for key, value in opts.items():
|
|
92
|
+
normalized[key] = _one(key, value)
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def native_payload(opts: dict[str, Any], *, include_tokens: bool) -> dict[str, Any]:
|
|
97
|
+
"""JSON-ready native options. Defaults that match upstream are omitted."""
|
|
98
|
+
payload: dict[str, Any] = {}
|
|
99
|
+
if opts.get("profile") not in (None, "standard"):
|
|
100
|
+
payload["profile"] = opts["profile"]
|
|
101
|
+
if "hashes" in opts and opts["hashes"] is not None:
|
|
102
|
+
payload["hashes"] = opts["hashes"]
|
|
103
|
+
if opts.get("privacy") not in (None, "strict"):
|
|
104
|
+
payload["privacy"] = opts["privacy"]
|
|
105
|
+
for key in ("only", "skip", "format"):
|
|
106
|
+
if key in opts and opts[key] is not None:
|
|
107
|
+
payload[key] = opts[key]
|
|
108
|
+
if opts.get("provenance"):
|
|
109
|
+
payload["provenance"] = True
|
|
110
|
+
if opts.get("limits"):
|
|
111
|
+
payload["limits"] = opts["limits"]
|
|
112
|
+
if include_tokens and "tokens" in opts:
|
|
113
|
+
payload["tokens"] = opts["tokens"]
|
|
114
|
+
return payload
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _one(key: str, value: Any) -> Any:
|
|
118
|
+
if key == "profile":
|
|
119
|
+
return _choice(key, value, _PROFILES)
|
|
120
|
+
if key == "privacy":
|
|
121
|
+
return _choice(key, value, _PRIVACY)
|
|
122
|
+
if key == "mode":
|
|
123
|
+
return _choice(key, value, _MODES)
|
|
124
|
+
if key == "stage":
|
|
125
|
+
return _choice(key, value, _STAGES)
|
|
126
|
+
if key == "on_error":
|
|
127
|
+
return _choice(key, value, ("collect", "halt"))
|
|
128
|
+
if key == "hashes":
|
|
129
|
+
return _hashes(value)
|
|
130
|
+
if key in {"only", "skip"}:
|
|
131
|
+
return _strings(key, value, "be a list of extractor id strings")
|
|
132
|
+
if key == "override_fields":
|
|
133
|
+
return _paths(value)
|
|
134
|
+
if key in {"format", "name", "filename", "mime"}:
|
|
135
|
+
if isinstance(value, str) and value:
|
|
136
|
+
return value
|
|
137
|
+
raise InvalidOptionError(f"{key} must be a non-empty string, got: {value!r}")
|
|
138
|
+
if key in {"provenance", "strict"}:
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return value
|
|
141
|
+
raise InvalidOptionError(f"{key} must be a boolean, got: {value!r}")
|
|
142
|
+
if key == "tokens":
|
|
143
|
+
return _int_range(key, value, 1, 100_000)
|
|
144
|
+
if key == "plugin_timeout_ms":
|
|
145
|
+
return _int_range(key, value, 1, 600_000)
|
|
146
|
+
if key == "max_concurrency":
|
|
147
|
+
return _int_range(key, value, 1, 128)
|
|
148
|
+
if key == "timeout":
|
|
149
|
+
if value is None:
|
|
150
|
+
return None
|
|
151
|
+
return _int_range(key, value, 1, 3_600_000, extra=" or None")
|
|
152
|
+
if key == "limits":
|
|
153
|
+
return _limits(value)
|
|
154
|
+
if key in {"extractors", "analyzers"}:
|
|
155
|
+
return _plugins(key, value)
|
|
156
|
+
raise InvalidOptionError(f"unknown option(s): [{key!r}]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _choice(key: str, value: Any, allowed: tuple[str, ...]) -> str:
|
|
160
|
+
if isinstance(value, str) and value in allowed:
|
|
161
|
+
return value
|
|
162
|
+
raise InvalidOptionError(f"{key} must be one of {list(allowed)}, got: {value!r}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _hashes(value: Any) -> str | list[str]:
|
|
166
|
+
if isinstance(value, str) and value in {"all", "none"}:
|
|
167
|
+
return value
|
|
168
|
+
if isinstance(value, (list, tuple)) and value and all(item in _HASHES for item in value):
|
|
169
|
+
seen: list[str] = []
|
|
170
|
+
for item in value:
|
|
171
|
+
if item not in seen:
|
|
172
|
+
seen.append(item)
|
|
173
|
+
return seen
|
|
174
|
+
raise InvalidOptionError(
|
|
175
|
+
f"hashes must be 'all', 'none', or a non-empty list of 'sha256' / 'blake3', got: {value!r}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _strings(key: str, value: Any, expectation: str) -> list[str]:
|
|
180
|
+
if isinstance(value, (list, tuple)) and all(isinstance(item, str) and item for item in value):
|
|
181
|
+
return list(value)
|
|
182
|
+
raise InvalidOptionError(f"{key} must {expectation}, got: {value!r}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _paths(value: Any) -> list[str]:
|
|
186
|
+
if isinstance(value, (list, tuple)) and all(_dotted(item) for item in value):
|
|
187
|
+
return list(value)
|
|
188
|
+
raise InvalidOptionError(
|
|
189
|
+
f'override_fields must be a list of dotted paths such as "content.title", got: {value!r}'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _dotted(value: object) -> bool:
|
|
194
|
+
if not isinstance(value, str) or "." not in value:
|
|
195
|
+
return False
|
|
196
|
+
parts = value.split(".")
|
|
197
|
+
return len(parts) >= 2 and all(parts)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _int_range(key: str, value: Any, low: int, high: int, extra: str = "") -> int:
|
|
201
|
+
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
|
|
202
|
+
raise InvalidOptionError(
|
|
203
|
+
f"{key} must be an integer in {low}..{high}{extra}, got: {value!r}"
|
|
204
|
+
)
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _limits(value: Any) -> dict[str, int]:
|
|
209
|
+
if not isinstance(value, dict):
|
|
210
|
+
raise InvalidOptionError(f"limits must be a dict, got: {value!r}")
|
|
211
|
+
unknown = sorted(key for key in value if key not in LIMIT_CAPS)
|
|
212
|
+
if unknown:
|
|
213
|
+
raise InvalidOptionError(f"unknown limit key(s): {unknown}")
|
|
214
|
+
out: dict[str, int] = {}
|
|
215
|
+
for key, item in value.items():
|
|
216
|
+
if isinstance(item, bool) or not isinstance(item, int) or item < 0:
|
|
217
|
+
raise InvalidOptionError(f"{key} must be a non-negative integer, got: {item!r}")
|
|
218
|
+
if item == 0 and key in _NONZERO:
|
|
219
|
+
raise InvalidOptionError(f"{key} must be greater than 0")
|
|
220
|
+
cap = LIMIT_CAPS[key]
|
|
221
|
+
if item > cap:
|
|
222
|
+
raise InvalidOptionError(f"{key} exceeds cap {cap}")
|
|
223
|
+
out[key] = item
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _plugins(key: str, value: Any) -> list[Any]:
|
|
228
|
+
if not isinstance(value, (list, tuple)):
|
|
229
|
+
raise InvalidOptionError(f"{key} must be a list")
|
|
230
|
+
needed = ("id", "supports", "extract") if key == "extractors" else ("id", "analyze")
|
|
231
|
+
bad = [item for item in value if any(not hasattr(item, attr) for attr in needed)]
|
|
232
|
+
if bad:
|
|
233
|
+
names = ", ".join(needed)
|
|
234
|
+
raise InvalidOptionError(
|
|
235
|
+
f"{key} must provide {names}: {bad!r}",
|
|
236
|
+
details={"invalid": [repr(item) for item in bad]},
|
|
237
|
+
)
|
|
238
|
+
return list(value)
|
whiff/plugins.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Run Python extractors, then analyzers, after native extraction.
|
|
2
|
+
|
|
3
|
+
Plugins run on a worker thread with ``plugin_timeout_ms``. A failure is
|
|
4
|
+
recorded in ``diagnostics.extractors`` unless ``strict`` is set, in which
|
|
5
|
+
case it is raised. Plugins are not a security boundary: a native crash inside
|
|
6
|
+
a plugin still takes down the process. Error text stored in the report is
|
|
7
|
+
privacy-scrubbed and truncated, and does not include arbitrary exception
|
|
8
|
+
messages.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from concurrent.futures import TimeoutError as FuturesTimeout
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from whiff.errors import (
|
|
19
|
+
AnalyzerError,
|
|
20
|
+
ExtractorError,
|
|
21
|
+
WhiffError,
|
|
22
|
+
WhiffTimeoutError,
|
|
23
|
+
error_for,
|
|
24
|
+
)
|
|
25
|
+
from whiff.input import Input
|
|
26
|
+
from whiff.merge import merge_tracked, validate_patch
|
|
27
|
+
from whiff.privacy import scrub_patch, scrub_string
|
|
28
|
+
from whiff.report import append_extractor, extractor_ids, native_covered, unreadable
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Extractor:
|
|
32
|
+
"""Base class for a plugin that adds fields after native extraction.
|
|
33
|
+
|
|
34
|
+
``supports`` is called with the :class:`~whiff.input.Input` and the report
|
|
35
|
+
so far. ``extract`` returns a patch dict. With ``mode="fallback"`` (the
|
|
36
|
+
default) ``extract`` runs only when native extraction did not cover a
|
|
37
|
+
readable file.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id: str = ""
|
|
41
|
+
version: int = 1
|
|
42
|
+
|
|
43
|
+
def supports(self, source: Input, report: dict[str, Any]) -> bool:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def extract(self, source: Input, report: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Analyzer:
|
|
51
|
+
"""Base class for a plugin that runs after extractors.
|
|
52
|
+
|
|
53
|
+
``analyze`` returns a patch merged in ``augment`` mode: it can add fields
|
|
54
|
+
and append keywords, risks, and warnings, and it does not replace values
|
|
55
|
+
already set.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
id: str = ""
|
|
59
|
+
version: int = 1
|
|
60
|
+
|
|
61
|
+
def analyze(self, report: dict[str, Any], source: Input) -> dict[str, Any]:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_MAX_ID_BYTES = 128
|
|
66
|
+
_MAX_VERSION = 4_294_967_295
|
|
67
|
+
_MAX_ERROR_BYTES = 256
|
|
68
|
+
_META_TIMEOUT_MS = 1_000
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run(source: Input, report: dict[str, Any], opts: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
ctx = _Ctx(
|
|
73
|
+
mode=opts.get("mode", "fallback"),
|
|
74
|
+
strict=bool(opts.get("strict", False)),
|
|
75
|
+
timeout_ms=int(opts.get("plugin_timeout_ms", 5_000)),
|
|
76
|
+
privacy=opts.get("privacy", "strict"),
|
|
77
|
+
provenance=bool(opts.get("provenance", False)),
|
|
78
|
+
override_fields=list(opts.get("override_fields") or []),
|
|
79
|
+
native_ids=set(extractor_ids(report)),
|
|
80
|
+
covered=native_covered(report),
|
|
81
|
+
unreadable=unreadable(report),
|
|
82
|
+
)
|
|
83
|
+
report, owned = _reduce(
|
|
84
|
+
opts.get("extractors") or [], report, set(), source, ctx, kind="extractor"
|
|
85
|
+
)
|
|
86
|
+
report, _owned = _reduce(
|
|
87
|
+
opts.get("analyzers") or [], report, owned, source, ctx, kind="analyzer"
|
|
88
|
+
)
|
|
89
|
+
return report
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Ctx:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
mode: str,
|
|
97
|
+
strict: bool,
|
|
98
|
+
timeout_ms: int,
|
|
99
|
+
privacy: str,
|
|
100
|
+
provenance: bool,
|
|
101
|
+
override_fields: list[str],
|
|
102
|
+
native_ids: set[str],
|
|
103
|
+
covered: bool,
|
|
104
|
+
unreadable: bool,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.mode = mode
|
|
107
|
+
self.strict = strict
|
|
108
|
+
self.timeout_ms = timeout_ms
|
|
109
|
+
self.privacy = privacy
|
|
110
|
+
self.provenance = provenance
|
|
111
|
+
self.override_fields = override_fields
|
|
112
|
+
self.native_ids = native_ids
|
|
113
|
+
self.covered = covered
|
|
114
|
+
self.unreadable = unreadable
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _reduce(plugins, report, owned, source, ctx, *, kind: str):
|
|
118
|
+
for plugin in plugins:
|
|
119
|
+
report, owned = _one(plugin, source, report, owned, ctx, kind)
|
|
120
|
+
return report, owned
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _one(plugin, source, report, owned, ctx: _Ctx, kind: str):
|
|
124
|
+
obj = plugin() if isinstance(plugin, type) else plugin
|
|
125
|
+
meta, err = _metadata(obj, kind, ctx)
|
|
126
|
+
if err is not None:
|
|
127
|
+
return _failure(report, owned, err, meta, ctx)
|
|
128
|
+
if kind == "extractor":
|
|
129
|
+
supported, err = _call(
|
|
130
|
+
lambda: obj.supports(source, report) is True,
|
|
131
|
+
timeout_ms=ctx.timeout_ms,
|
|
132
|
+
kind=kind,
|
|
133
|
+
plugin_id=meta["id"],
|
|
134
|
+
)
|
|
135
|
+
if err is not None:
|
|
136
|
+
return _failure(report, owned, err, meta, ctx)
|
|
137
|
+
if not supported:
|
|
138
|
+
return report, owned
|
|
139
|
+
if ctx.mode == "fallback" and (ctx.covered or ctx.unreadable):
|
|
140
|
+
return report, owned
|
|
141
|
+
started = time.perf_counter()
|
|
142
|
+
result, err = _call(
|
|
143
|
+
lambda: obj.extract(source, report),
|
|
144
|
+
timeout_ms=ctx.timeout_ms,
|
|
145
|
+
kind=kind,
|
|
146
|
+
plugin_id=meta["id"],
|
|
147
|
+
)
|
|
148
|
+
meta = {**meta, "ms": (time.perf_counter() - started) * 1000}
|
|
149
|
+
mode = ctx.mode
|
|
150
|
+
else:
|
|
151
|
+
started = time.perf_counter()
|
|
152
|
+
result, err = _call(
|
|
153
|
+
lambda: obj.analyze(report, source),
|
|
154
|
+
timeout_ms=ctx.timeout_ms,
|
|
155
|
+
kind=kind,
|
|
156
|
+
plugin_id=meta["id"],
|
|
157
|
+
)
|
|
158
|
+
meta = {**meta, "ms": (time.perf_counter() - started) * 1000}
|
|
159
|
+
mode = "augment"
|
|
160
|
+
if err is not None:
|
|
161
|
+
return _failure(report, owned, err, meta, ctx)
|
|
162
|
+
if not isinstance(result, dict):
|
|
163
|
+
reason = "analyzer_error" if kind == "analyzer" else "extractor_error"
|
|
164
|
+
err = error_for(reason, f"{kind} returned {_shape(result)}; expected a dict")
|
|
165
|
+
return _failure(report, owned, err, meta, ctx)
|
|
166
|
+
return _apply(report, owned, result, mode, meta, ctx)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _metadata(obj, kind: str, ctx: _Ctx) -> tuple[dict[str, Any], WhiffError | None]:
|
|
170
|
+
fallback = {"id": type(obj).__name__, "version": 0, "ms": 0.0, "kind": kind}
|
|
171
|
+
|
|
172
|
+
def read():
|
|
173
|
+
plugin_id = obj.id() if callable(obj.id) else obj.id
|
|
174
|
+
version = (
|
|
175
|
+
obj.version() if callable(getattr(obj, "version", None)) else getattr(obj, "version", 0)
|
|
176
|
+
)
|
|
177
|
+
return plugin_id, version
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
plugin_id, version = _invoke(read, min(ctx.timeout_ms, _META_TIMEOUT_MS))
|
|
181
|
+
except WhiffTimeoutError as err:
|
|
182
|
+
return fallback, err
|
|
183
|
+
except WhiffError as err:
|
|
184
|
+
return fallback, err
|
|
185
|
+
except Exception as err:
|
|
186
|
+
reason = "analyzer_error" if kind == "analyzer" else "extractor_error"
|
|
187
|
+
return fallback, error_for(
|
|
188
|
+
reason, f"plugin raised {type(err).__name__}", details={"plugin": fallback["id"]}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
reason = "analyzer_error" if kind == "analyzer" else "extractor_error"
|
|
192
|
+
if not _valid_id(plugin_id):
|
|
193
|
+
return fallback, error_for(
|
|
194
|
+
reason,
|
|
195
|
+
f"id must be a non-empty string (max {_MAX_ID_BYTES} bytes)",
|
|
196
|
+
)
|
|
197
|
+
if (
|
|
198
|
+
isinstance(version, bool)
|
|
199
|
+
or not isinstance(version, int)
|
|
200
|
+
or not 0 <= version <= _MAX_VERSION
|
|
201
|
+
):
|
|
202
|
+
return fallback, error_for(reason, f"version must be an integer in 0..{_MAX_VERSION}")
|
|
203
|
+
if plugin_id in ctx.native_ids:
|
|
204
|
+
return fallback, error_for(
|
|
205
|
+
"conflict", f"plugin id `{plugin_id}` collides with a native extractor id"
|
|
206
|
+
)
|
|
207
|
+
return {**fallback, "id": plugin_id, "version": version}, None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _valid_id(plugin_id: object) -> bool:
|
|
211
|
+
return (
|
|
212
|
+
isinstance(plugin_id, str)
|
|
213
|
+
and plugin_id != ""
|
|
214
|
+
and len(plugin_id.encode("utf-8")) <= _MAX_ID_BYTES
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _call(fn, *, timeout_ms: int, kind: str, plugin_id: str):
|
|
219
|
+
try:
|
|
220
|
+
return _invoke(fn, timeout_ms), None
|
|
221
|
+
except WhiffError as err:
|
|
222
|
+
err.details = {**err.details, "plugin": plugin_id}
|
|
223
|
+
return None, err
|
|
224
|
+
except FuturesTimeout:
|
|
225
|
+
return None, WhiffTimeoutError(
|
|
226
|
+
f"plugin timed out after {timeout_ms}ms",
|
|
227
|
+
details={"plugin": plugin_id},
|
|
228
|
+
)
|
|
229
|
+
except Exception as err:
|
|
230
|
+
reason_cls = AnalyzerError if kind == "analyzer" else ExtractorError
|
|
231
|
+
return None, reason_cls(
|
|
232
|
+
f"plugin raised {type(err).__name__}",
|
|
233
|
+
details={"plugin": plugin_id, "class": type(err).__name__},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _invoke(fn, timeout_ms: int):
|
|
238
|
+
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="whiff-plugin")
|
|
239
|
+
try:
|
|
240
|
+
future = pool.submit(fn)
|
|
241
|
+
try:
|
|
242
|
+
return future.result(timeout=timeout_ms / 1000)
|
|
243
|
+
except FuturesTimeout:
|
|
244
|
+
raise WhiffTimeoutError(f"plugin timed out after {timeout_ms}ms") from None
|
|
245
|
+
finally:
|
|
246
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _apply(report, owned: set[str], patch: dict, mode: str, meta: dict, ctx: _Ctx):
|
|
250
|
+
try:
|
|
251
|
+
validate_patch(patch)
|
|
252
|
+
patch = scrub_patch(patch, ctx.privacy)
|
|
253
|
+
merged, written = merge_tracked(
|
|
254
|
+
report,
|
|
255
|
+
patch,
|
|
256
|
+
mode=mode,
|
|
257
|
+
override_fields=ctx.override_fields,
|
|
258
|
+
plugin_id=meta["id"],
|
|
259
|
+
provenance=ctx.provenance,
|
|
260
|
+
owned=owned,
|
|
261
|
+
)
|
|
262
|
+
except WhiffError as err:
|
|
263
|
+
return _failure(report, owned, err, meta, ctx)
|
|
264
|
+
entry = {"id": meta["id"], "version": meta["version"], "ms": meta["ms"], "status": "ok"}
|
|
265
|
+
return append_extractor(merged, entry), owned | set(written)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _failure(report, owned, err: WhiffError, meta: dict, ctx: _Ctx):
|
|
269
|
+
err = _sanitize(err, ctx)
|
|
270
|
+
err.details = {**err.details, "plugin": meta["id"]}
|
|
271
|
+
if ctx.strict:
|
|
272
|
+
raise err
|
|
273
|
+
entry = {
|
|
274
|
+
"id": meta["id"],
|
|
275
|
+
"version": meta["version"],
|
|
276
|
+
"ms": meta["ms"],
|
|
277
|
+
"status": "error",
|
|
278
|
+
"error_kind": err.reason,
|
|
279
|
+
"error": err.message,
|
|
280
|
+
}
|
|
281
|
+
return append_extractor(report, entry), owned
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _sanitize(err: WhiffError, ctx: _Ctx) -> WhiffError:
|
|
285
|
+
message = scrub_string(err.message, ctx.privacy) if isinstance(err.message, str) else ""
|
|
286
|
+
err.message = _truncate(message)
|
|
287
|
+
err.args = (err._format(),)
|
|
288
|
+
return err
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _truncate(message: str) -> str:
|
|
292
|
+
raw = message.encode("utf-8")
|
|
293
|
+
if len(raw) <= _MAX_ERROR_BYTES:
|
|
294
|
+
return message
|
|
295
|
+
cut = raw[:_MAX_ERROR_BYTES]
|
|
296
|
+
while cut:
|
|
297
|
+
try:
|
|
298
|
+
return cut.decode("utf-8") + "..."
|
|
299
|
+
except UnicodeDecodeError:
|
|
300
|
+
cut = cut[:-1]
|
|
301
|
+
return "..."
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _shape(value: object) -> str:
|
|
305
|
+
if isinstance(value, tuple):
|
|
306
|
+
return f"a tuple of size {len(value)}"
|
|
307
|
+
if isinstance(value, dict):
|
|
308
|
+
return "a dict"
|
|
309
|
+
if isinstance(value, list):
|
|
310
|
+
return "a list"
|
|
311
|
+
if isinstance(value, str):
|
|
312
|
+
return "a str"
|
|
313
|
+
if value is None:
|
|
314
|
+
return "None"
|
|
315
|
+
return type(value).__name__
|