python-statemachine 3.2.0__py3-none-any.whl → 3.2.2__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.
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/METADATA +7 -6
- python_statemachine-3.2.2.dist-info/RECORD +73 -0
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/WHEEL +1 -1
- statemachine/__init__.py +1 -1
- statemachine/callbacks.py +11 -0
- statemachine/contrib/diagram/__init__.py +15 -6
- statemachine/contrib/diagram/extract.py +3 -3
- statemachine/contrib/diagram/formatter.py +4 -2
- statemachine/contrib/diagram/graphviz.py +66 -0
- statemachine/contrib/diagram/sphinx_ext.py +28 -7
- statemachine/dispatcher.py +8 -3
- statemachine/engines/async_.py +224 -180
- statemachine/engines/base.py +257 -245
- statemachine/engines/sync.py +224 -108
- statemachine/event.py +29 -10
- statemachine/event_data.py +19 -3
- statemachine/events.py +5 -2
- statemachine/exceptions.py +1 -1
- statemachine/factory.py +126 -29
- statemachine/io/__init__.py +18 -11
- statemachine/io/actions.py +124 -15
- statemachine/io/evaluators.py +37 -9
- statemachine/io/invoke.py +12 -0
- statemachine/io/loader.py +17 -3
- statemachine/io/schemas/statechart.schema.json +8 -1
- statemachine/io/scxml/__init__.py +6 -3
- statemachine/io/scxml/processor.py +1 -1
- statemachine/io/scxml/reader.py +24 -9
- statemachine/io/system_variables.py +34 -19
- statemachine/locale/en/LC_MESSAGES/statemachine.po +58 -17
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +66 -17
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +66 -17
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +60 -17
- statemachine/orderedset.py +12 -3
- statemachine/registry.py +6 -1
- statemachine/spec_parser.py +151 -21
- statemachine/state.py +47 -8
- statemachine/statemachine.py +42 -19
- python_statemachine-3.2.0.dist-info/RECORD +0 -72
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/licenses/LICENSE +0 -0
statemachine/factory.py
CHANGED
|
@@ -5,6 +5,8 @@ from . import registry
|
|
|
5
5
|
from .callbacks import CallbackGroup
|
|
6
6
|
from .callbacks import CallbackPriority
|
|
7
7
|
from .callbacks import CallbackSpecList
|
|
8
|
+
from .callbacks import SpecReference
|
|
9
|
+
from .callbacks import is_unbounded_reference
|
|
8
10
|
from .event import Event
|
|
9
11
|
from .event import _expand_event_id
|
|
10
12
|
from .exceptions import InvalidDefinition
|
|
@@ -13,12 +15,25 @@ from .graph import iterate_states
|
|
|
13
15
|
from .graph import iterate_states_and_transitions
|
|
14
16
|
from .graph import states_without_path_to_final_states
|
|
15
17
|
from .i18n import _
|
|
18
|
+
from .spec_parser import names_in_boolean_expr
|
|
16
19
|
from .state import State
|
|
17
20
|
from .states import States
|
|
18
21
|
from .transition import Transition
|
|
19
22
|
from .transition_list import TransitionList
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
def _same_callable(func: Any, other: Any) -> bool:
|
|
26
|
+
return type(func) is type(other) and func == other
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _can_store_apart(existing: Any, owners: list[tuple[State, Any]]) -> bool:
|
|
30
|
+
"""Only callbacks held by object can move to a key of their own: a name is still
|
|
31
|
+
reachable through ``self.<name>``, so it must not lose a plain method or attribute."""
|
|
32
|
+
return all(is_unbounded_reference(func) for _, func in owners) and (
|
|
33
|
+
existing is None or isinstance(existing, Event) or is_unbounded_reference(existing)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
22
37
|
class StateMachineMetaclass(type):
|
|
23
38
|
"Metaclass for constructing StateMachine classes"
|
|
24
39
|
|
|
@@ -51,7 +66,7 @@ class StateMachineMetaclass(type):
|
|
|
51
66
|
cls._abstract = True
|
|
52
67
|
cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
|
|
53
68
|
cls._protected_attrs: set = set()
|
|
54
|
-
cls._events_to_update: dict[Event, Event
|
|
69
|
+
cls._events_to_update: dict[Event, list[Event]] = {}
|
|
55
70
|
cls._specs = CallbackSpecList()
|
|
56
71
|
cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
|
|
57
72
|
"prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
|
|
@@ -59,7 +74,6 @@ class StateMachineMetaclass(type):
|
|
|
59
74
|
cls.add_inherited(bases)
|
|
60
75
|
cls.add_from_attributes(attrs)
|
|
61
76
|
cls._collect_class_listeners(attrs, bases)
|
|
62
|
-
cls._unpack_builders_callbacks()
|
|
63
77
|
cls._update_event_references()
|
|
64
78
|
|
|
65
79
|
if not cls.states:
|
|
@@ -88,6 +102,7 @@ class StateMachineMetaclass(type):
|
|
|
88
102
|
|
|
89
103
|
cls._check()
|
|
90
104
|
cls._setup()
|
|
105
|
+
cls._unpack_builders_callbacks(attrs)
|
|
91
106
|
cls._expand_docstring()
|
|
92
107
|
|
|
93
108
|
_STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")
|
|
@@ -163,14 +178,96 @@ class StateMachineMetaclass(type):
|
|
|
163
178
|
if not any(t for t in parent.transitions if t.initial and t.target == state):
|
|
164
179
|
parent.to(state, initial=True) # pragma: no cover
|
|
165
180
|
|
|
166
|
-
def _unpack_builders_callbacks(cls):
|
|
167
|
-
|
|
181
|
+
def _unpack_builders_callbacks(cls, attrs: dict[str, Any]):
|
|
182
|
+
names_resolved_by_name = cls._names_resolved_by_name()
|
|
183
|
+
relocated: dict[Any, str] = {}
|
|
184
|
+
for attr_name, owners in cls._nested_callbacks().items():
|
|
185
|
+
existing = cls.__dict__.get(attr_name)
|
|
186
|
+
owners = [(s, f) for s, f in owners if not _same_callable(f, existing)]
|
|
187
|
+
if not owners:
|
|
188
|
+
continue
|
|
189
|
+
if existing is None and len(owners) == 1:
|
|
190
|
+
setattr(cls, attr_name, owners[0][1])
|
|
191
|
+
continue
|
|
192
|
+
if attr_name in names_resolved_by_name or not _can_store_apart(existing, owners):
|
|
193
|
+
cls._raise_callback_collision(attr_name, existing, owners, attrs)
|
|
194
|
+
for state, func in owners:
|
|
195
|
+
relocated[func] = f"{state.id}:{attr_name}"
|
|
196
|
+
setattr(cls, relocated[func], func)
|
|
197
|
+
own_states = [value for value in attrs.values() if isinstance(value, State)]
|
|
198
|
+
cls._update_lookup_names(relocated, own_states)
|
|
199
|
+
|
|
200
|
+
def _nested_callbacks(cls) -> dict[str, list[tuple[State, Any]]]:
|
|
201
|
+
"""Distinct callables declared under each name, with the state body declaring them."""
|
|
202
|
+
claims: dict[str, list[tuple[State, Any]]] = {}
|
|
168
203
|
for state in iterate_states(cls.states):
|
|
169
204
|
if state._callbacks:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
205
|
+
for attr_name, func in state._callbacks.items():
|
|
206
|
+
owners = claims.setdefault(attr_name, [])
|
|
207
|
+
if not any(_same_callable(func, other) for _, other in owners):
|
|
208
|
+
owners.append((state, func))
|
|
209
|
+
state._callbacks = None
|
|
210
|
+
return claims
|
|
211
|
+
|
|
212
|
+
def _update_lookup_names(cls, relocated: dict[Any, str], own_states: list[State]):
|
|
213
|
+
"""Inherited states share their specs with the base class, which must keep its own."""
|
|
214
|
+
if not relocated:
|
|
215
|
+
return
|
|
216
|
+
for visited in iterate_states_and_transitions(own_states):
|
|
217
|
+
for spec in visited._specs:
|
|
218
|
+
if spec.reference is SpecReference.CALLABLE and spec.func in relocated:
|
|
219
|
+
spec.lookup_name = relocated[spec.func]
|
|
220
|
+
|
|
221
|
+
def _all_specs(cls):
|
|
222
|
+
yield cls._specs
|
|
223
|
+
for visited in iterate_states_and_transitions(cls.states):
|
|
224
|
+
yield visited._specs
|
|
225
|
+
|
|
226
|
+
def _names_resolved_by_name(cls) -> set[str]:
|
|
227
|
+
names: set[str] = set()
|
|
228
|
+
for specs in cls._all_specs():
|
|
229
|
+
for spec in specs:
|
|
230
|
+
if spec.may_contain_boolean_expression:
|
|
231
|
+
names.update(names_in_boolean_expr(spec.attr_name))
|
|
232
|
+
elif spec.reference is SpecReference.NAME:
|
|
233
|
+
names.add(spec.attr_name)
|
|
234
|
+
return names
|
|
235
|
+
|
|
236
|
+
def _raise_callback_collision(
|
|
237
|
+
cls,
|
|
238
|
+
attr_name: str,
|
|
239
|
+
existing: Any,
|
|
240
|
+
owners: list[tuple[State, Any]],
|
|
241
|
+
attrs: dict[str, Any],
|
|
242
|
+
):
|
|
243
|
+
class_body_names = set(attrs)
|
|
244
|
+
class_body_names.update(
|
|
245
|
+
value.attr_name for value in attrs.values() if getattr(value, "attr_name", None)
|
|
246
|
+
)
|
|
247
|
+
state, func = owners[0]
|
|
248
|
+
if existing is None:
|
|
249
|
+
conflict = _("is declared in both the '{}' and the '{}' state bodies").format(
|
|
250
|
+
state.id, owners[1][0].id
|
|
251
|
+
)
|
|
252
|
+
elif isinstance(existing, Event):
|
|
253
|
+
conflict = _("in the '{}' state body clashes with the event '{}'").format(
|
|
254
|
+
state.id, existing.id
|
|
255
|
+
)
|
|
256
|
+
elif attr_name in class_body_names:
|
|
257
|
+
conflict = _("is declared in both the '{}' state body and the '{}' class body").format(
|
|
258
|
+
state.id, cls.__name__
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
conflict = _("in the '{}' state body clashes with the '{}' attribute of '{}'").format(
|
|
262
|
+
state.id, attr_name, cls.__name__
|
|
263
|
+
)
|
|
264
|
+
name = func.__name__ if getattr(func, "is_event", False) else attr_name
|
|
265
|
+
raise InvalidDefinition(
|
|
266
|
+
_(
|
|
267
|
+
"Callback '{}' {}. Nested state bodies share the class namespace. "
|
|
268
|
+
"Rename one of them."
|
|
269
|
+
).format(name, conflict)
|
|
270
|
+
)
|
|
174
271
|
|
|
175
272
|
def _check(cls):
|
|
176
273
|
has_states = bool(cls.states)
|
|
@@ -305,21 +402,21 @@ class StateMachineMetaclass(type):
|
|
|
305
402
|
if isinstance(value, State):
|
|
306
403
|
cls.add_state(key, value)
|
|
307
404
|
elif isinstance(value, (Transition, TransitionList)):
|
|
308
|
-
event_id
|
|
309
|
-
|
|
405
|
+
for event_id in _expand_event_id(key):
|
|
406
|
+
cls.add_event(event=Event(transitions=value, id=event_id))
|
|
310
407
|
elif isinstance(value, (Event,)):
|
|
311
|
-
if value._has_real_id
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
408
|
+
event_ids = [value.id] if value._has_real_id else _expand_event_id(key)
|
|
409
|
+
for event_id in event_ids:
|
|
410
|
+
new_event = Event(
|
|
411
|
+
transitions=value._transitions,
|
|
412
|
+
id=event_id,
|
|
413
|
+
name=value.name,
|
|
414
|
+
delay=value.delay,
|
|
415
|
+
internal=value.internal,
|
|
416
|
+
)
|
|
417
|
+
cls.add_event(event=new_event, old_event=value)
|
|
321
418
|
# Ensure the event is accessible by the Python attribute name
|
|
322
|
-
if
|
|
419
|
+
if key not in event_ids:
|
|
323
420
|
setattr(cls, key, new_event)
|
|
324
421
|
elif getattr(value, "attr_name", None):
|
|
325
422
|
cls._add_unbounded_callback(key, value)
|
|
@@ -334,7 +431,8 @@ class StateMachineMetaclass(type):
|
|
|
334
431
|
# machinery that is stored at ``func.attr_name``
|
|
335
432
|
setattr(cls, func.attr_name, func)
|
|
336
433
|
if func.is_event:
|
|
337
|
-
|
|
434
|
+
for event_id in _expand_event_id(attr_name):
|
|
435
|
+
cls.add_event(event=Event(func._transitions, id=event_id))
|
|
338
436
|
|
|
339
437
|
def add_state(cls, id, state: State):
|
|
340
438
|
state._set_id(id)
|
|
@@ -357,8 +455,7 @@ class StateMachineMetaclass(type):
|
|
|
357
455
|
old_event: "Event | None" = None,
|
|
358
456
|
):
|
|
359
457
|
if not event._has_real_id:
|
|
360
|
-
|
|
361
|
-
cls._events_to_update[event] = None
|
|
458
|
+
cls._events_to_update.setdefault(event, [])
|
|
362
459
|
return
|
|
363
460
|
|
|
364
461
|
transitions = event._transitions
|
|
@@ -370,20 +467,20 @@ class StateMachineMetaclass(type):
|
|
|
370
467
|
setattr(cls, event.id, event)
|
|
371
468
|
|
|
372
469
|
if old_event is not None:
|
|
373
|
-
cls._events_to_update[
|
|
470
|
+
cls._events_to_update.setdefault(old_event, []).append(event)
|
|
374
471
|
|
|
375
472
|
return cls._events[event]
|
|
376
473
|
|
|
377
474
|
def _update_event_references(cls):
|
|
378
|
-
for old_event,
|
|
475
|
+
for old_event, new_events in cls._events_to_update.items():
|
|
379
476
|
for state in cls.states:
|
|
380
477
|
for transition in state.transitions:
|
|
381
|
-
if transition._events
|
|
382
|
-
if
|
|
478
|
+
if old_event in transition._events:
|
|
479
|
+
if not new_events:
|
|
383
480
|
raise InvalidDefinition(
|
|
384
481
|
_("An event in the '{}' has no id.").format(transition)
|
|
385
482
|
)
|
|
386
|
-
transition.events._replace(old_event,
|
|
483
|
+
transition.events._replace(old_event, new_events)
|
|
387
484
|
|
|
388
485
|
cls._events_to_update = {}
|
|
389
486
|
|
statemachine/io/__init__.py
CHANGED
|
@@ -11,19 +11,26 @@ whether it was authored in SCXML, JSON or YAML.
|
|
|
11
11
|
Security
|
|
12
12
|
--------
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
default** (``trusted=False``): expressions are evaluated by a **restricted
|
|
17
|
-
evaluator** that cannot reach builtins, dunder attributes
|
|
18
|
-
``<script>`` / ``script`` (arbitrary code) is rejected
|
|
19
|
-
|
|
14
|
+
A statechart is an *executable document*, not inert data, so loading one is closer to importing
|
|
15
|
+
code than to parsing JSON. Because a document may come from a semi-trusted source, loading is
|
|
16
|
+
**secure by default** (``trusted=False``): expressions are evaluated by a **restricted
|
|
17
|
+
AST-allowlist evaluator** that cannot reach builtins, dunder attributes or arbitrary calls;
|
|
18
|
+
``<script>`` / ``script`` (arbitrary code) is rejected; external resource references
|
|
19
|
+
(``<data src>`` / ``<invoke src>``, which read local files) are rejected; write targets are
|
|
20
|
+
confined to public model attributes; and ``**``/``*`` are magnitude-capped.
|
|
20
21
|
|
|
21
|
-
Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation
|
|
22
|
-
In that mode a document is equivalent to executable Python
|
|
23
|
-
**only load ``trusted`` documents from sources you control**
|
|
24
|
-
output of your own tooling, the W3C conformance suite).
|
|
22
|
+
Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation, enables ``script`` and
|
|
23
|
+
allows external ``src`` references. In that mode a document is equivalent to executable Python
|
|
24
|
+
(much like :mod:`pickle`), so **only load ``trusted`` documents from sources you control**
|
|
25
|
+
(hand-authored documents, the output of your own tooling, the W3C conformance suite).
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
The intended use of ``trusted=False`` is your own dynamic definitions (a no-code editor, your
|
|
28
|
+
tooling, config you control); it is *hardening*, not a sandbox. It guarantees confidentiality
|
|
29
|
+
and integrity, not availability: a *running* machine can still exhaust resources (eventless
|
|
30
|
+
loops, large ``<foreach>``, recursive ``<invoke>``), so run genuinely untrusted documents under
|
|
31
|
+
your own timeout/resource limits or OS-level isolation. See the GHSA-v4jc-pm6r-3vj8,
|
|
32
|
+
GHSA-fj3w-533r-fvf6, GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc and GHSA-r8gj-366q-cgvj advisories,
|
|
33
|
+
and ``docs/io/security.md``, for details.
|
|
27
34
|
"""
|
|
28
35
|
|
|
29
36
|
from .class_factory import ActionProtocol
|
statemachine/io/actions.py
CHANGED
|
@@ -8,13 +8,16 @@ is format-neutral: it is shared by the SCXML, JSON and YAML readers.
|
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
10
|
import re
|
|
11
|
+
import types
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from itertools import chain
|
|
13
14
|
from typing import Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
14
16
|
from uuid import uuid4
|
|
15
17
|
|
|
16
18
|
from ..event import BoundEvent
|
|
17
19
|
from ..event import Event
|
|
20
|
+
from ..event import raise_delayed_internal
|
|
18
21
|
from ..statemachine import StateChart
|
|
19
22
|
from .evaluators import Evaluator
|
|
20
23
|
from .evaluators import protected_attrs
|
|
@@ -37,6 +40,80 @@ logger = logging.getLogger(__name__)
|
|
|
37
40
|
_debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
|
|
38
41
|
|
|
39
42
|
|
|
43
|
+
def _ensure_writable_target(name: str, descriptor: str) -> None:
|
|
44
|
+
"""Reject a document-controlled write target that isn't a plain public attribute.
|
|
45
|
+
|
|
46
|
+
Executable content chooses the *name* it writes to: ``<assign location>`` (each dotted
|
|
47
|
+
segment), ``<foreach item>``/``index``, ``<data id>`` and ``<send>``/``<invoke>``
|
|
48
|
+
``idlocation``. A private, dunder or engine-protected name lets an untrusted document
|
|
49
|
+
reach internals or, by traversing ``__class__``, corrupt the shared model class
|
|
50
|
+
process-wide (GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc). Only a valid, public (no
|
|
51
|
+
leading ``_``), non-protected identifier may be written. Raised as ``ValueError`` so the
|
|
52
|
+
engine routes it to ``error.execution`` like any other executable-content failure.
|
|
53
|
+
"""
|
|
54
|
+
if not name.isidentifier() or name.startswith("_") or name in protected_attrs:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"{descriptor} cannot write to a private, dunder or protected name: {name!r}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reject_type_or_module(obj: Any, descriptor: str) -> None:
|
|
61
|
+
"""Reject traversing through or writing onto a class or module.
|
|
62
|
+
|
|
63
|
+
A class (``isinstance(obj, type)``) or module is shared process-wide, so a ``setattr``
|
|
64
|
+
on it (or a hop through it) corrupts every user of that class/module, not just this
|
|
65
|
+
machine. Even if an untrusted expression manages to obtain such an object as a value
|
|
66
|
+
(e.g. via a leaked engine capability), the write target guard blocks the mutation
|
|
67
|
+
(GHSA-v3qq-3xvg-m77g). Plain data instances are unaffected. Raised as ``ValueError`` so
|
|
68
|
+
the engine routes it to ``error.execution``.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(obj, type) or isinstance(obj, types.ModuleType):
|
|
71
|
+
raise ValueError(f"{descriptor} cannot traverse or write onto a class or module: {obj!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _reject_engine_capability(obj: Any, descriptor: str) -> None:
|
|
75
|
+
"""Reject traversing through or writing onto a live engine-capability object.
|
|
76
|
+
|
|
77
|
+
Beyond classes and modules (:func:`_reject_type_or_module`), the engine's own runtime
|
|
78
|
+
objects are shared, mutable capabilities: the machine
|
|
79
|
+
(:class:`~statemachine.statemachine.StateChart`, and thus ``StateMachine``), the io
|
|
80
|
+
:class:`~statemachine.io.interpreter.Interpreter`, the core
|
|
81
|
+
:class:`~statemachine.state.State` / :class:`~statemachine.transition.Transition` /
|
|
82
|
+
:class:`~statemachine.event.Event`, the system-variable facades
|
|
83
|
+
(:class:`~statemachine.io.system_variables.EventDataWrapper` /
|
|
84
|
+
:class:`~statemachine.io.system_variables.IOProcessor`) and the event carriers
|
|
85
|
+
(:class:`~statemachine.event_data.EventData` / :class:`~statemachine.event_data.TriggerData`).
|
|
86
|
+
A ``setattr`` on any of them (or a hop through one) mutates state shared across sibling
|
|
87
|
+
machine instances of the same loaded class. Even if some public alias ever leaks such
|
|
88
|
+
an object as a value, this blocks pivoting the write onto a shared object
|
|
89
|
+
(GHSA-v3qq-3xvg-m77g). The datamodel container (:class:`~statemachine.model.Model`, the
|
|
90
|
+
legitimate ``machine.model`` root) and plain data (dict/list/str/int/ordinary app model
|
|
91
|
+
instances) are unaffected. Lazy imports keep this off the module import cycle. Raised as
|
|
92
|
+
``ValueError`` so the engine routes it to ``error.execution``.
|
|
93
|
+
"""
|
|
94
|
+
from ..event_data import EventData
|
|
95
|
+
from ..event_data import TriggerData
|
|
96
|
+
from ..state import State
|
|
97
|
+
from ..transition import Transition
|
|
98
|
+
from .interpreter import Interpreter
|
|
99
|
+
from .system_variables import EventDataWrapper
|
|
100
|
+
from .system_variables import IOProcessor
|
|
101
|
+
|
|
102
|
+
engine_types = (
|
|
103
|
+
StateChart,
|
|
104
|
+
Interpreter,
|
|
105
|
+
State,
|
|
106
|
+
Transition,
|
|
107
|
+
Event,
|
|
108
|
+
EventDataWrapper,
|
|
109
|
+
IOProcessor,
|
|
110
|
+
EventData,
|
|
111
|
+
TriggerData,
|
|
112
|
+
)
|
|
113
|
+
if isinstance(obj, engine_types):
|
|
114
|
+
raise ValueError(f"{descriptor} cannot traverse or write onto an engine object: {obj!r}")
|
|
115
|
+
|
|
116
|
+
|
|
40
117
|
class ParseTime:
|
|
41
118
|
pattern = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(s|ms)")
|
|
42
119
|
"""CSS2 time literal: a number (``5``, ``1.5``, ``.5``) followed by ``s`` or ``ms``."""
|
|
@@ -164,20 +241,30 @@ class Assign(CallableAction):
|
|
|
164
241
|
value = self._expr(*args, **kwargs) # type: ignore[misc]
|
|
165
242
|
|
|
166
243
|
*path, attr = self.action.location.split(".")
|
|
244
|
+
descriptor = f"<assign> 'location' ({self.action.location!r})"
|
|
167
245
|
obj = machine.model
|
|
246
|
+
# Validate every hop (initial, intermediate and final): traversing a private/dunder
|
|
247
|
+
# object (e.g. ``__class__``) or writing a protected name is how an untrusted document
|
|
248
|
+
# escapes the datamodel, validating only the final segment left that open. A class or
|
|
249
|
+
# module (``_reject_type_or_module``) and a live engine-capability instance
|
|
250
|
+
# (``_reject_engine_capability``: machine/interpreter/State/Transition/Event and the
|
|
251
|
+
# system-variable facades) are additionally rejected as a hop or write target, so even
|
|
252
|
+
# a value that leaked such an object cannot be pivoted onto shared state
|
|
253
|
+
# (GHSA-v3qq-3xvg-m77g).
|
|
168
254
|
for p in path:
|
|
255
|
+
_ensure_writable_target(p, descriptor)
|
|
256
|
+
_reject_type_or_module(obj, descriptor)
|
|
257
|
+
_reject_engine_capability(obj, descriptor)
|
|
169
258
|
obj = getattr(obj, p)
|
|
170
259
|
|
|
171
|
-
|
|
260
|
+
_ensure_writable_target(attr, descriptor)
|
|
261
|
+
_reject_type_or_module(obj, descriptor)
|
|
262
|
+
_reject_engine_capability(obj, descriptor)
|
|
263
|
+
if not (hasattr(obj, attr) or attr in kwargs):
|
|
172
264
|
raise ValueError(
|
|
173
|
-
f"<assign> 'location' must be
|
|
265
|
+
f"<assign> 'location' must be declared before assignment, "
|
|
174
266
|
f"got: {self.action.location}"
|
|
175
267
|
)
|
|
176
|
-
if attr in protected_attrs:
|
|
177
|
-
raise ValueError(
|
|
178
|
-
f"<assign> 'location' cannot assign to a protected attribute: "
|
|
179
|
-
f"{self.action.location}"
|
|
180
|
-
)
|
|
181
268
|
setattr(obj, attr, value)
|
|
182
269
|
_debug("Assign: %s = %r", self.action.location, value)
|
|
183
270
|
|
|
@@ -242,10 +329,9 @@ def create_foreach_action_callable(action: ForeachAction, evaluator: Evaluator)
|
|
|
242
329
|
except Exception as e:
|
|
243
330
|
raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
|
|
244
331
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
332
|
+
_ensure_writable_target(action.item, "<foreach> 'item'")
|
|
333
|
+
if action.index:
|
|
334
|
+
_ensure_writable_target(action.index, "<foreach> 'index'")
|
|
249
335
|
for index, item in enumerate(array):
|
|
250
336
|
# Assign the item and optionally the index
|
|
251
337
|
setattr(machine.model, action.item, item)
|
|
@@ -315,6 +401,9 @@ def create_send_action_callable( # noqa: C901
|
|
|
315
401
|
) -> Callable:
|
|
316
402
|
content: Any = ()
|
|
317
403
|
_valid_targets = (None, "#_internal", "internal", "#_parent", "parent")
|
|
404
|
+
internal = action.target in ("#_internal", "internal")
|
|
405
|
+
if internal and (action.delay or action.delayexpr):
|
|
406
|
+
raise_delayed_internal(action.event or action.eventexpr or "")
|
|
318
407
|
if action.content:
|
|
319
408
|
content = (evaluator.eval_literal(action.content),)
|
|
320
409
|
delay_expr = (
|
|
@@ -348,12 +437,11 @@ def create_send_action_callable( # noqa: C901
|
|
|
348
437
|
_send_to_parent(action, evaluator, **kwargs)
|
|
349
438
|
return
|
|
350
439
|
|
|
351
|
-
internal = target in ("#_internal", "internal")
|
|
352
|
-
|
|
353
440
|
send_id = None
|
|
354
441
|
if action.id:
|
|
355
442
|
send_id = action.id
|
|
356
443
|
elif action.idlocation:
|
|
444
|
+
_ensure_writable_target(action.idlocation, "<send> 'idlocation'")
|
|
357
445
|
send_id = uuid4().hex
|
|
358
446
|
setattr(machine.model, action.idlocation, send_id)
|
|
359
447
|
|
|
@@ -408,12 +496,33 @@ def create_script_action_callable(action: ScriptAction, evaluator: Evaluator) ->
|
|
|
408
496
|
return script_action
|
|
409
497
|
|
|
410
498
|
|
|
499
|
+
def _resolve_data_src(action: DataItem, evaluator: Evaluator) -> "str | None":
|
|
500
|
+
"""Read a ``<data src="file:…">`` reference at compile time, gated on trust.
|
|
501
|
+
|
|
502
|
+
Only the ``file`` scheme is resolved (mirroring the SCXML reader's historical
|
|
503
|
+
behaviour). Reading a local file named by an untrusted document is a confidentiality
|
|
504
|
+
risk, so the restricted evaluator rejects it; ``trusted=True`` reads it as before.
|
|
505
|
+
Runs inside the document's base directory (``loader._build`` ``_chdir``), so relative
|
|
506
|
+
paths resolve against the document.
|
|
507
|
+
"""
|
|
508
|
+
if action.src is None:
|
|
509
|
+
return None
|
|
510
|
+
src_parsed = urlparse(action.src)
|
|
511
|
+
if src_parsed.scheme != "file":
|
|
512
|
+
return None
|
|
513
|
+
evaluator.ensure_external_src_allowed(f"<data id={action.id!r} src=...>")
|
|
514
|
+
with open(src_parsed.path) as f:
|
|
515
|
+
return f.read()
|
|
516
|
+
|
|
517
|
+
|
|
411
518
|
def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callable:
|
|
519
|
+
content = action.content if action.content else _resolve_data_src(action, evaluator)
|
|
412
520
|
expr_fn = evaluator.compile_value(action.expr) if action.expr else None
|
|
413
|
-
content_fn = evaluator.compile_value(
|
|
521
|
+
content_fn = evaluator.compile_value(content) if content else None
|
|
414
522
|
|
|
415
523
|
def data_initializer(**kwargs):
|
|
416
524
|
machine: StateChart = kwargs["machine"]
|
|
525
|
+
_ensure_writable_target(action.id, "<data> 'id'")
|
|
417
526
|
|
|
418
527
|
# Check for invoke param overrides — params from parent override child defaults
|
|
419
528
|
invoke_params = getattr(machine, "_invoke_params", None)
|
|
@@ -432,7 +541,7 @@ def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callabl
|
|
|
432
541
|
try:
|
|
433
542
|
value = content_fn(**kwargs)
|
|
434
543
|
except Exception:
|
|
435
|
-
value =
|
|
544
|
+
value = content
|
|
436
545
|
else:
|
|
437
546
|
value = None
|
|
438
547
|
|
statemachine/io/evaluators.py
CHANGED
|
@@ -5,7 +5,7 @@ neutral IR (:mod:`statemachine.io.model`) into callables, deciding *how* they ar
|
|
|
5
5
|
evaluated:
|
|
6
6
|
|
|
7
7
|
- :class:`RestrictedEvaluator` (secure default): compiles expressions with the
|
|
8
|
-
AST-
|
|
8
|
+
AST-allowlist evaluator (:func:`statemachine.spec_parser.parse_expr`), which
|
|
9
9
|
cannot reach builtins, dunder attributes, or arbitrary calls. ``<script>`` and
|
|
10
10
|
raw-Python evaluation are rejected because they are arbitrary code.
|
|
11
11
|
- :class:`PythonEvaluator` (opt-in, ``trusted=True``): preserves the legacy
|
|
@@ -18,9 +18,13 @@ but name resolution and value computation still happen at call time, so runtime
|
|
|
18
18
|
errors (``NameError``, ``TypeError``) keep flowing to ``error.execution`` as
|
|
19
19
|
before.
|
|
20
20
|
|
|
21
|
+
The evaluator is also the trust gate for external resource references: an external
|
|
22
|
+
``<data src>`` / ``<invoke src>`` reads a local file, so the restricted evaluator rejects
|
|
23
|
+
it via :meth:`ensure_external_src_allowed` (the trusted one allows it).
|
|
24
|
+
|
|
21
25
|
This module is format-neutral: it is shared by the SCXML, JSON and YAML readers.
|
|
22
26
|
See the :mod:`statemachine.io.scxml` package docstring and the GHSA-v4jc-pm6r-3vj8
|
|
23
|
-
|
|
27
|
+
(code execution) and GHSA-fj3w-533r-fvf6 (file disclosure) advisories for the rationale.
|
|
24
28
|
"""
|
|
25
29
|
|
|
26
30
|
import ast
|
|
@@ -82,13 +86,17 @@ def _resolvable_model_attr(name: str, model) -> bool:
|
|
|
82
86
|
def variable_hook(name: str) -> Callable:
|
|
83
87
|
"""Resolve a datamodel name at call time.
|
|
84
88
|
|
|
85
|
-
Looks the name up in the call kwargs first (
|
|
86
|
-
``_event``, ``_name
|
|
87
|
-
|
|
89
|
+
Looks the name up in the call kwargs first (SCXML system variables such as
|
|
90
|
+
``_event``, ``_name``), then falls back to a (non-protected, non-private) attribute of
|
|
91
|
+
``machine.model``. Names in :data:`~statemachine.event._event_data_kwargs` (``machine``,
|
|
92
|
+
``model``, ``event``, ``transition``, ...) are the live engine-capability objects; they
|
|
93
|
+
are **withheld** from the kwargs lookup so an untrusted expression cannot obtain the
|
|
94
|
+
engine and pivot through it onto shared classes (GHSA-v3qq-3xvg-m77g). The ``_``-prefixed
|
|
95
|
+
system variables are not engine objects and stay resolvable.
|
|
88
96
|
"""
|
|
89
97
|
|
|
90
98
|
def resolver(*args, **kwargs):
|
|
91
|
-
if name in kwargs:
|
|
99
|
+
if name not in _event_data_kwargs and name in kwargs:
|
|
92
100
|
return kwargs[name]
|
|
93
101
|
model = kwargs["machine"].model
|
|
94
102
|
if _resolvable_model_attr(name, model):
|
|
@@ -109,7 +117,7 @@ def cond_variable_hook(name: str) -> Callable:
|
|
|
109
117
|
"""
|
|
110
118
|
|
|
111
119
|
def resolver(*args, **kwargs):
|
|
112
|
-
if name in kwargs:
|
|
120
|
+
if name not in _event_data_kwargs and name in kwargs:
|
|
113
121
|
return kwargs[name]
|
|
114
122
|
model = kwargs["machine"].model
|
|
115
123
|
if _resolvable_model_attr(name, model):
|
|
@@ -166,9 +174,20 @@ class Evaluator(Protocol):
|
|
|
166
174
|
"""Evaluate inline ``<content>`` as a literal, falling back to the raw string."""
|
|
167
175
|
...
|
|
168
176
|
|
|
177
|
+
def ensure_external_src_allowed( # pragma: no cover - structural Protocol
|
|
178
|
+
self, descriptor: str
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Guard external resource references (``<data src>`` / ``<invoke src>``).
|
|
181
|
+
|
|
182
|
+
Reading a local file named by an untrusted document is a confidentiality risk, so
|
|
183
|
+
it is gated on the trust level just like ``<script>``. ``descriptor`` names the
|
|
184
|
+
offending element for the error message.
|
|
185
|
+
"""
|
|
186
|
+
...
|
|
187
|
+
|
|
169
188
|
|
|
170
189
|
class RestrictedEvaluator:
|
|
171
|
-
"""Secure default: AST-
|
|
190
|
+
"""Secure default: AST-allowlist evaluation, no ``eval``/``exec``.
|
|
172
191
|
|
|
173
192
|
``<script>`` and any expression outside the supported subset (method calls,
|
|
174
193
|
dunder attribute access, lambdas, comprehensions, ...) are rejected at
|
|
@@ -208,6 +227,12 @@ class RestrictedEvaluator:
|
|
|
208
227
|
except (ValueError, SyntaxError):
|
|
209
228
|
return content
|
|
210
229
|
|
|
230
|
+
def ensure_external_src_allowed(self, descriptor: str) -> None:
|
|
231
|
+
raise InvalidDefinition(
|
|
232
|
+
f"{descriptor} loads a local file and is disabled by default. Pass "
|
|
233
|
+
"trusted=True to enable it, and only for trusted sources."
|
|
234
|
+
)
|
|
235
|
+
|
|
211
236
|
@staticmethod
|
|
212
237
|
def _compile(expr: str, hook: Callable) -> Callable:
|
|
213
238
|
try:
|
|
@@ -235,7 +260,7 @@ class PythonEvaluator:
|
|
|
235
260
|
|
|
236
261
|
def compile_bool(self, expr: str) -> Callable:
|
|
237
262
|
# Intentionally uses ``eval`` (not ``parse_expr``): the whole point of the
|
|
238
|
-
# trusted evaluator is to accept guards the restricted AST
|
|
263
|
+
# trusted evaluator is to accept guards the restricted AST allowlist rejects —
|
|
239
264
|
# method/function calls, builtins, etc. (e.g. SCXML conformance conds like
|
|
240
265
|
# ``_event.data.get('x') == 1`` or ``hasattr(_event, 'name')``). Routing this
|
|
241
266
|
# through ``parse_expr`` would make trusted mode no more capable than the
|
|
@@ -263,6 +288,9 @@ class PythonEvaluator:
|
|
|
263
288
|
except (NameError, SyntaxError, TypeError):
|
|
264
289
|
return content
|
|
265
290
|
|
|
291
|
+
def ensure_external_src_allowed(self, descriptor: str) -> None:
|
|
292
|
+
return None
|
|
293
|
+
|
|
266
294
|
|
|
267
295
|
def evaluator_for(trusted: bool = False) -> Evaluator:
|
|
268
296
|
"""Return the evaluation strategy for the given trust level (the wiring point).
|
statemachine/io/invoke.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing import Any
|
|
|
20
20
|
from ..invoke import IInvoke
|
|
21
21
|
from ..invoke import InvokeContext
|
|
22
22
|
from .actions import ExecuteBlock
|
|
23
|
+
from .actions import _ensure_writable_target
|
|
23
24
|
from .evaluators import Evaluator
|
|
24
25
|
from .model import InvokeDefinition
|
|
25
26
|
from .model import StateMachineDefinition
|
|
@@ -57,6 +58,12 @@ class Invoker:
|
|
|
57
58
|
self._base_dir: str = base_dir
|
|
58
59
|
self._evaluator: Evaluator = evaluator
|
|
59
60
|
|
|
61
|
+
# A static ``src`` is known at load time, so reject it now (like <data src> /
|
|
62
|
+
# <script>) for a clean, propagating error. A dynamic ``srcexpr`` can only be
|
|
63
|
+
# resolved at runtime and is gated again in ``_resolve_content``.
|
|
64
|
+
if definition.content is None and definition.src is not None:
|
|
65
|
+
self._evaluator.ensure_external_src_allowed("<invoke src=...>")
|
|
66
|
+
|
|
60
67
|
# Duck-typed attributes for InvokeManager
|
|
61
68
|
self.invoke_id: "str | None" = definition.id
|
|
62
69
|
self.idlocation: "str | None" = definition.idlocation
|
|
@@ -76,6 +83,7 @@ class Invoker:
|
|
|
76
83
|
|
|
77
84
|
# Store invokeid in idlocation if specified
|
|
78
85
|
if self.idlocation:
|
|
86
|
+
_ensure_writable_target(self.idlocation, "<invoke> 'idlocation'")
|
|
79
87
|
setattr(machine.model, self.idlocation, ctx.invokeid)
|
|
80
88
|
|
|
81
89
|
# Resolve invoke type
|
|
@@ -176,6 +184,10 @@ class Invoker:
|
|
|
176
184
|
else:
|
|
177
185
|
return None
|
|
178
186
|
|
|
187
|
+
# Reading a local file named by an untrusted document is a confidentiality risk,
|
|
188
|
+
# so it is gated on the trust level (mirrors <script> / <data src>).
|
|
189
|
+
self._evaluator.ensure_external_src_allowed("<invoke src=...>")
|
|
190
|
+
|
|
179
191
|
# Handle file: URIs and relative paths
|
|
180
192
|
if src.startswith("file:"):
|
|
181
193
|
path = Path(src.removeprefix("file:"))
|