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/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__