python-statemachine 3.2.0__py3-none-any.whl → 3.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +1 -1
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.1.dist-info}/RECORD +22 -22
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.1.dist-info}/WHEEL +1 -1
- statemachine/__init__.py +1 -1
- statemachine/contrib/diagram/extract.py +3 -3
- statemachine/dispatcher.py +5 -1
- statemachine/engines/async_.py +15 -3
- statemachine/engines/base.py +14 -3
- statemachine/exceptions.py +1 -1
- statemachine/io/__init__.py +18 -11
- statemachine/io/actions.py +120 -13
- statemachine/io/evaluators.py +37 -9
- statemachine/io/invoke.py +12 -0
- statemachine/io/loader.py +17 -3
- 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/orderedset.py +12 -3
- statemachine/spec_parser.py +69 -15
- statemachine/state.py +2 -2
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
statemachine/__init__.py,sha256=
|
|
1
|
+
statemachine/__init__.py,sha256=roXmQG7d48QWxlQUM94bfLMVTiJ6iZnqnhQ-qkkFHv8,445
|
|
2
2
|
statemachine/callbacks.py,sha256=LKX9GnDhN64yYh2EvDEYNJg0-N61Kmyd0aqTZKxujM4,14526
|
|
3
3
|
statemachine/configuration.py,sha256=MblvaBwEOyooYc4Vzo-qeryF9sEmx9mdtZDY0Gf_uRU,5991
|
|
4
|
-
statemachine/dispatcher.py,sha256=
|
|
4
|
+
statemachine/dispatcher.py,sha256=fyJKGhOdmHAzsBB7zfoU1hPAauoC7IEyBC2C-yYzBcw,8438
|
|
5
5
|
statemachine/event.py,sha256=hYVvsNpsY0-OSebwDEpxdLXadoJS_G5pmDe34hRqnFk,7645
|
|
6
6
|
statemachine/event_data.py,sha256=7_hoE0cp-0uW71Kk3erO3eYM6anTT5X3slGMcehIGIo,3111
|
|
7
7
|
statemachine/events.py,sha256=11GyX8GYpsSwvW0v1b4zHUGhzKRoqNjQYtuDj8NvqOM,1212
|
|
8
|
-
statemachine/exceptions.py,sha256=
|
|
8
|
+
statemachine/exceptions.py,sha256=q1ZOqBmNv34mtHAzv4XHBV67BHldxtEpu3uoeTXiEYI,1317
|
|
9
9
|
statemachine/factory.py,sha256=qIgDxBwa5xa0lgvTaV4QPqdYqqkXZCzlSq0sX4TjZvo,14640
|
|
10
10
|
statemachine/graph.py,sha256=ngQ31YAetfQLQvdBU5cHwLQcD-wJL92axPJ2sUeCu1g,1999
|
|
11
11
|
statemachine/i18n.py,sha256=NLvGseaORmQ0G-V_J8tkjoxh_piWMOm2CI6mBQpLamc,362
|
|
12
12
|
statemachine/invoke.py,sha256=5B7XNvBFnQaKByoYM1VbRlJvJyEwXn1C0QKUCaRjmpU,24908
|
|
13
13
|
statemachine/mixins.py,sha256=8qxZZfBwVdFcr3oPFVWHGzgmAubH6VgQus5x3c6VEE0,1706
|
|
14
14
|
statemachine/model.py,sha256=OylI3FjMiHpYyDl9mtK1zEJMeSvemaN4giQDonpc8kI,211
|
|
15
|
-
statemachine/orderedset.py,sha256=
|
|
15
|
+
statemachine/orderedset.py,sha256=a2FqEAAKja6E0Bu5O5Lzbd18V4hECrrCbinguIdzTjk,2794
|
|
16
16
|
statemachine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
17
|
statemachine/registry.py,sha256=TmHfVU7HgaG43slG5lh8EL-8237Fm8r2UkwDj8u-t0c,700
|
|
18
18
|
statemachine/signature.py,sha256=-IiRYJScx4cDKny3ictQvHwQBbaCyxWpRKSgQksMT0E,9834
|
|
19
|
-
statemachine/spec_parser.py,sha256=
|
|
20
|
-
statemachine/state.py,sha256=
|
|
19
|
+
statemachine/spec_parser.py,sha256=hc4dTr8gjKjg-xyG9UA8iTQ-t8EY6Df361fDv5QFsO8,16133
|
|
20
|
+
statemachine/state.py,sha256=Do-Fw5ZUYQBAXjXmBIGzQ9hnmn7TrmdmpvP8z6Nqp6g,15436
|
|
21
21
|
statemachine/statemachine.py,sha256=27TRypjsh3lwlAGZAwqrt-Cnka65uBoaxMWrU27gg6M,19941
|
|
22
22
|
statemachine/states.py,sha256=NRHtATtb-2Ac2nwzSn5OfHwzOSxI2FLeNR8o1v84USs,4827
|
|
23
23
|
statemachine/transition.py,sha256=JtGABHJ6inZrneI70bgu4ir2BZ2YQagcYX9l6TcsHSc,6912
|
|
@@ -29,7 +29,7 @@ statemachine/contrib/timeout.py,sha256=VXkdLpxNeUKihkbtLv8XTWdG-meQlSFtxJfcxXgV8
|
|
|
29
29
|
statemachine/contrib/weighted.py,sha256=lzcopcY0EzjJb-N4APJJc7AlgZIPtthTbBizKaDtOIk,6787
|
|
30
30
|
statemachine/contrib/diagram/__init__.py,sha256=mvTluFDnhE2OzTeOhyU7aQebl79LIOsvOiNFAVIldww,7730
|
|
31
31
|
statemachine/contrib/diagram/__main__.py,sha256=iPpiz09xKqtAjrhONS99OYp6R2dQ6Anbhw1qPIN8ELo,80
|
|
32
|
-
statemachine/contrib/diagram/extract.py,sha256=
|
|
32
|
+
statemachine/contrib/diagram/extract.py,sha256=0X9RsHFgQgxDGaz6GGbFnJacZyxRO7sV7PolpWiFbdw,9869
|
|
33
33
|
statemachine/contrib/diagram/formatter.py,sha256=lYdBGO3VSZnM9OipyNsuA6StMEEjrRdnksm9MmzKXBA,4724
|
|
34
34
|
statemachine/contrib/diagram/model.py,sha256=pn7dzrt8U8TOn4A-Jg1Rc3b1UUlTgs75WfR2x-xtEuc,1451
|
|
35
35
|
statemachine/contrib/diagram/sphinx_ext.py,sha256=HcEXU-aYz8da02XPkZKpF5HDF0XPBGGg9FCz8MtI1C8,10338
|
|
@@ -38,35 +38,35 @@ statemachine/contrib/diagram/renderers/dot.py,sha256=VkWMVP1DLl1YUEUZVmBVSNQVbAG
|
|
|
38
38
|
statemachine/contrib/diagram/renderers/mermaid.py,sha256=3N1z0klonL768exBcWp3WLMuWUgCpRso2UhpdnvHYhY,13424
|
|
39
39
|
statemachine/contrib/diagram/renderers/table.py,sha256=PLVfwTIZHtv7uK3smAfN1owAOEunkBRHyEkh5ygVcfw,3711
|
|
40
40
|
statemachine/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
|
-
statemachine/engines/async_.py,sha256=
|
|
42
|
-
statemachine/engines/base.py,sha256=
|
|
41
|
+
statemachine/engines/async_.py,sha256=luSBqLaemcLoaI8PGcpWV8L-VMyEQhOZ2Tl57bEPuP4,22991
|
|
42
|
+
statemachine/engines/base.py,sha256=qVMe0s-L4aSXA4XfQ9MUnzGHgS1ifTZ7F6rSujTOqLE,38465
|
|
43
43
|
statemachine/engines/sync.py,sha256=F_bt4z1KLLcFCP77E9SNWDJMWiMTexE0iOU2XAd_x5A,9153
|
|
44
|
-
statemachine/io/__init__.py,sha256=
|
|
45
|
-
statemachine/io/actions.py,sha256=
|
|
44
|
+
statemachine/io/__init__.py,sha256=BMA91t_PY0fmsjIfMeq8pQ6Cs4KS1gxyrZj_DtMntRA,2769
|
|
45
|
+
statemachine/io/actions.py,sha256=wd0bgL63Rdi2a6RUkobeIvAYaNrNpJHVjZ7kP5P9blw,24377
|
|
46
46
|
statemachine/io/builder.py,sha256=yNaQrtFfbs55uRd-loZ-Vs5061liwM-np-B_2ZuJO1I,8350
|
|
47
47
|
statemachine/io/class_factory.py,sha256=H8zxsfrjLw7L_X2g2hoXb2CjpHxTftsoBmPBWIcen-4,9557
|
|
48
|
-
statemachine/io/evaluators.py,sha256=
|
|
48
|
+
statemachine/io/evaluators.py,sha256=A6g_o_8GuYMcSFOWJ1dsVRzr06ddikybCI-xAbY3vhY,12178
|
|
49
49
|
statemachine/io/interpreter.py,sha256=FQUXUlt1PSPzFHyixiLNXcN-7KYBM_i5RAFPRPLu6TE,5567
|
|
50
|
-
statemachine/io/invoke.py,sha256=
|
|
51
|
-
statemachine/io/loader.py,sha256=
|
|
50
|
+
statemachine/io/invoke.py,sha256=iPYck_vP2vh7-bRXkMwOYHryrXCqWxNhLzFE8uiG6L8,10632
|
|
51
|
+
statemachine/io/loader.py,sha256=g_X6NgI2mudpwHN6EoDCaDv4HsryFpO93lExPo_lOdU,7054
|
|
52
52
|
statemachine/io/model.py,sha256=mTJvlbDJndX78pHfyDdEaqc9zTBWVA-OFA5xzVZbz6Y,8695
|
|
53
53
|
statemachine/io/native.py,sha256=HjIOKDQIGExLMglcm8VGbga71Y1Zw-pBbKYWP-y_oiY,13758
|
|
54
54
|
statemachine/io/ports.py,sha256=XoIAQfMujkEnH38t4iu766yiHycb89TNrA3owtBJU2U,2956
|
|
55
|
-
statemachine/io/system_variables.py,sha256=
|
|
55
|
+
statemachine/io/system_variables.py,sha256=yYoAJTCm1uNIp63Dyw0wSUKDJbg1gQGgWKfvBt6LyGQ,7278
|
|
56
56
|
statemachine/io/validation.py,sha256=6qiDx0gMkPAehSxJu_mrW4nEF_sebHwa5Dr_njclRcw,1662
|
|
57
57
|
statemachine/io/json/__init__.py,sha256=tFWoOrD9RuKEqkOpI0XifNRP7rguSGY4k-RaSCV_GnM,38
|
|
58
58
|
statemachine/io/json/reader.py,sha256=40V5T-yB1i0Qg-MkK00KHRi7eDQQk4ajfzmKtKXylHs,789
|
|
59
59
|
statemachine/io/schemas/statechart.schema.json,sha256=5yNIS4M7OV6GE2doAWMk_Jct-qDyb5kQ-Dq95Es9KbM,7862
|
|
60
|
-
statemachine/io/scxml/__init__.py,sha256=
|
|
61
|
-
statemachine/io/scxml/processor.py,sha256=
|
|
62
|
-
statemachine/io/scxml/reader.py,sha256=
|
|
60
|
+
statemachine/io/scxml/__init__.py,sha256=0CMGGfpOaindNGb6VOkMn7Z4nGqK3iJ3sMZcS531Kaw,972
|
|
61
|
+
statemachine/io/scxml/processor.py,sha256=wk6Tt7yTf2GxCwx5S3eT9NInMsObspPmpQFLTNkacSk,1532
|
|
62
|
+
statemachine/io/scxml/reader.py,sha256=gp6tglHXdNxnPa9jNoo9jvRqNUfIr3O_DjCQJQijbD4,17821
|
|
63
63
|
statemachine/io/yaml/__init__.py,sha256=oMapUmquV7hb5qIGMfjDZmDHNq2fqqjJw6LOgGNKeC4,79
|
|
64
64
|
statemachine/io/yaml/reader.py,sha256=mf2FaBttxQuCniSM-vHhSYDGaNLQdzRLoMUHO2kZ0xA,2208
|
|
65
65
|
statemachine/locale/en/LC_MESSAGES/statemachine.po,sha256=_HrrNLQK1krOb56x5Pf1S6LihwaPwhN9_T0PUvumRA0,5338
|
|
66
66
|
statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po,sha256=O15jEhCNATAu6O2WhQ9RhQISB6Ueje2xbbTfEotnVSc,7948
|
|
67
67
|
statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po,sha256=X6-fU4JfK7VPFCRuLQ-4J-YUMF9N_RwhZ1DdCUvn74Y,5814
|
|
68
68
|
statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po,sha256=0_54wD7bl3RBMlYyokNFUdgMCx_1MbKzclxBUwmCNEs,5363
|
|
69
|
-
python_statemachine-3.2.
|
|
70
|
-
python_statemachine-3.2.
|
|
71
|
-
python_statemachine-3.2.
|
|
72
|
-
python_statemachine-3.2.
|
|
69
|
+
python_statemachine-3.2.1.dist-info/METADATA,sha256=1IBKPLjl5g4Ui0PIp6lUfCF8zL8yJ9_KLcKhmtsrM-w,12642
|
|
70
|
+
python_statemachine-3.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
71
|
+
python_statemachine-3.2.1.dist-info/licenses/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
|
|
72
|
+
python_statemachine-3.2.1.dist-info/RECORD,,
|
statemachine/__init__.py
CHANGED
|
@@ -91,7 +91,7 @@ def _extract_state(
|
|
|
91
91
|
|
|
92
92
|
return DiagramState(
|
|
93
93
|
id=state.id,
|
|
94
|
-
name=state.name,
|
|
94
|
+
name=str(state.name),
|
|
95
95
|
type=state_type,
|
|
96
96
|
actions=actions,
|
|
97
97
|
children=children,
|
|
@@ -125,7 +125,7 @@ def _format_event_names(transition: "Transition") -> str:
|
|
|
125
125
|
continue
|
|
126
126
|
if eid not in seen_ids: # pragma: no branch
|
|
127
127
|
seen_ids.add(eid)
|
|
128
|
-
display.append(event.name if event.name else eid)
|
|
128
|
+
display.append(str(event.name) if event.name else eid)
|
|
129
129
|
|
|
130
130
|
return " ".join(display)
|
|
131
131
|
|
|
@@ -279,7 +279,7 @@ def extract(machine_or_class: "MachineRef") -> DiagramGraph:
|
|
|
279
279
|
_resolve_initial_states(states)
|
|
280
280
|
|
|
281
281
|
return DiagramGraph(
|
|
282
|
-
name=machine.name,
|
|
282
|
+
name=str(machine.name),
|
|
283
283
|
states=states,
|
|
284
284
|
transitions=transitions,
|
|
285
285
|
compound_state_ids=compound_ids,
|
statemachine/dispatcher.py
CHANGED
|
@@ -14,6 +14,7 @@ from .event import Event
|
|
|
14
14
|
from .exceptions import InvalidDefinition
|
|
15
15
|
from .i18n import _
|
|
16
16
|
from .signature import SignatureAdapter
|
|
17
|
+
from .spec_parser import UnsupportedExpression
|
|
17
18
|
from .spec_parser import custom_and
|
|
18
19
|
from .spec_parser import operator_mapping
|
|
19
20
|
from .spec_parser import parse_boolean_expr
|
|
@@ -118,7 +119,10 @@ class Listeners:
|
|
|
118
119
|
|
|
119
120
|
try:
|
|
120
121
|
expression = parse_boolean_expr(spec.func, take_callback_partial, operator_mapping)
|
|
121
|
-
except SyntaxError as err:
|
|
122
|
+
except (SyntaxError, UnsupportedExpression) as err:
|
|
123
|
+
# ``UnsupportedExpression`` comes from the AST allowlist rejecting a node
|
|
124
|
+
# kind that cannot appear in a boolean expression (e.g. ``"user.age"``).
|
|
125
|
+
# Errors raised while resolving names are not parse errors and propagate.
|
|
122
126
|
raise InvalidDefinition(
|
|
123
127
|
_("Failed to parse boolean expression '{}'").format(spec.func)
|
|
124
128
|
) from err
|
statemachine/engines/async_.py
CHANGED
|
@@ -71,17 +71,27 @@ class AsyncEngine(BaseEngine):
|
|
|
71
71
|
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---
|
|
72
72
|
|
|
73
73
|
async def _get_args_kwargs(
|
|
74
|
-
self,
|
|
74
|
+
self,
|
|
75
|
+
transition: "Transition",
|
|
76
|
+
trigger_data: TriggerData,
|
|
77
|
+
target: "State | None" = None,
|
|
78
|
+
source: "State | None" = None,
|
|
75
79
|
):
|
|
76
|
-
cache_key = (id(transition), id(trigger_data), id(target))
|
|
80
|
+
cache_key = (id(transition), id(trigger_data), id(target), id(source))
|
|
77
81
|
|
|
78
82
|
if cache_key in self._cache:
|
|
79
83
|
return self._cache[cache_key]
|
|
80
84
|
|
|
81
85
|
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
86
|
+
# See the sync engine for the rationale: bind `state`/`target` to the
|
|
87
|
+
# entered state and `state`/`source` to the exited state, keeping the
|
|
88
|
+
# generic enter/exit callbacks symmetric across compound boundaries.
|
|
82
89
|
if target:
|
|
83
90
|
event_data.state = target
|
|
84
91
|
event_data.target = target
|
|
92
|
+
if source:
|
|
93
|
+
event_data.state = source
|
|
94
|
+
event_data.source = source
|
|
85
95
|
|
|
86
96
|
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
87
97
|
|
|
@@ -173,7 +183,9 @@ class AsyncEngine(BaseEngine):
|
|
|
173
183
|
if info.state is not None: # pragma: no branch
|
|
174
184
|
self._invoke_manager.cancel_for_state(info.state)
|
|
175
185
|
|
|
176
|
-
args, kwargs = await self._get_args_kwargs(
|
|
186
|
+
args, kwargs = await self._get_args_kwargs(
|
|
187
|
+
info.transition, trigger_data, source=info.state
|
|
188
|
+
)
|
|
177
189
|
|
|
178
190
|
if info.state is not None: # pragma: no branch
|
|
179
191
|
self._debug("%s Exiting state: %s", self._log_id, info.state)
|
statemachine/engines/base.py
CHANGED
|
@@ -419,19 +419,30 @@ class BaseEngine:
|
|
|
419
419
|
return result
|
|
420
420
|
|
|
421
421
|
def _get_args_kwargs(
|
|
422
|
-
self,
|
|
422
|
+
self,
|
|
423
|
+
transition: Transition,
|
|
424
|
+
trigger_data: TriggerData,
|
|
425
|
+
target: "State | None" = None,
|
|
426
|
+
source: "State | None" = None,
|
|
423
427
|
):
|
|
424
428
|
# Generate a unique key for the cache, the cache is invalidated once per loop
|
|
425
|
-
cache_key = (id(transition), id(trigger_data), id(target))
|
|
429
|
+
cache_key = (id(transition), id(trigger_data), id(target), id(source))
|
|
426
430
|
|
|
427
431
|
# Check the cache for existing results
|
|
428
432
|
if cache_key in self._cache:
|
|
429
433
|
return self._cache[cache_key]
|
|
430
434
|
|
|
431
435
|
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
436
|
+
# Bind `state`/`target` to the individual state being entered, and
|
|
437
|
+
# `state`/`source` to the individual state being exited, so the generic
|
|
438
|
+
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
|
|
439
|
+
# compound state boundaries (the transition only knows its own endpoints).
|
|
432
440
|
if target:
|
|
433
441
|
event_data.state = target
|
|
434
442
|
event_data.target = target
|
|
443
|
+
if source:
|
|
444
|
+
event_data.state = source
|
|
445
|
+
event_data.source = source
|
|
435
446
|
|
|
436
447
|
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
437
448
|
|
|
@@ -500,7 +511,7 @@ class BaseEngine:
|
|
|
500
511
|
if info.state is not None: # pragma: no branch
|
|
501
512
|
self._invoke_manager.cancel_for_state(info.state)
|
|
502
513
|
|
|
503
|
-
args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
|
|
514
|
+
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)
|
|
504
515
|
|
|
505
516
|
# Execute `onexit` handlers — same per-block error isolation as onentry.
|
|
506
517
|
if info.state is not None: # pragma: no branch
|
statemachine/exceptions.py
CHANGED
|
@@ -36,7 +36,7 @@ class TransitionNotAllowed(StateMachineError):
|
|
|
36
36
|
def __init__(self, event: "Event | None", configuration: MutableSet["State"]):
|
|
37
37
|
self.event = event
|
|
38
38
|
self.configuration = configuration
|
|
39
|
-
name = ", ".join(
|
|
39
|
+
name = ", ".join(str(s.name) for s in configuration)
|
|
40
40
|
msg = _("Can't {} when in {}.").format(
|
|
41
41
|
self.event and self.event.name or "transition", name
|
|
42
42
|
)
|
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,9 +8,11 @@ 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
|
|
@@ -37,6 +39,80 @@ logger = logging.getLogger(__name__)
|
|
|
37
39
|
_debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
def _ensure_writable_target(name: str, descriptor: str) -> None:
|
|
43
|
+
"""Reject a document-controlled write target that isn't a plain public attribute.
|
|
44
|
+
|
|
45
|
+
Executable content chooses the *name* it writes to: ``<assign location>`` (each dotted
|
|
46
|
+
segment), ``<foreach item>``/``index``, ``<data id>`` and ``<send>``/``<invoke>``
|
|
47
|
+
``idlocation``. A private, dunder or engine-protected name lets an untrusted document
|
|
48
|
+
reach internals or, by traversing ``__class__``, corrupt the shared model class
|
|
49
|
+
process-wide (GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc). Only a valid, public (no
|
|
50
|
+
leading ``_``), non-protected identifier may be written. Raised as ``ValueError`` so the
|
|
51
|
+
engine routes it to ``error.execution`` like any other executable-content failure.
|
|
52
|
+
"""
|
|
53
|
+
if not name.isidentifier() or name.startswith("_") or name in protected_attrs:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{descriptor} cannot write to a private, dunder or protected name: {name!r}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reject_type_or_module(obj: Any, descriptor: str) -> None:
|
|
60
|
+
"""Reject traversing through or writing onto a class or module.
|
|
61
|
+
|
|
62
|
+
A class (``isinstance(obj, type)``) or module is shared process-wide, so a ``setattr``
|
|
63
|
+
on it (or a hop through it) corrupts every user of that class/module, not just this
|
|
64
|
+
machine. Even if an untrusted expression manages to obtain such an object as a value
|
|
65
|
+
(e.g. via a leaked engine capability), the write target guard blocks the mutation
|
|
66
|
+
(GHSA-v3qq-3xvg-m77g). Plain data instances are unaffected. Raised as ``ValueError`` so
|
|
67
|
+
the engine routes it to ``error.execution``.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(obj, type) or isinstance(obj, types.ModuleType):
|
|
70
|
+
raise ValueError(f"{descriptor} cannot traverse or write onto a class or module: {obj!r}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _reject_engine_capability(obj: Any, descriptor: str) -> None:
|
|
74
|
+
"""Reject traversing through or writing onto a live engine-capability object.
|
|
75
|
+
|
|
76
|
+
Beyond classes and modules (:func:`_reject_type_or_module`), the engine's own runtime
|
|
77
|
+
objects are shared, mutable capabilities: the machine
|
|
78
|
+
(:class:`~statemachine.statemachine.StateChart`, and thus ``StateMachine``), the io
|
|
79
|
+
:class:`~statemachine.io.interpreter.Interpreter`, the core
|
|
80
|
+
:class:`~statemachine.state.State` / :class:`~statemachine.transition.Transition` /
|
|
81
|
+
:class:`~statemachine.event.Event`, the system-variable facades
|
|
82
|
+
(:class:`~statemachine.io.system_variables.EventDataWrapper` /
|
|
83
|
+
:class:`~statemachine.io.system_variables.IOProcessor`) and the event carriers
|
|
84
|
+
(:class:`~statemachine.event_data.EventData` / :class:`~statemachine.event_data.TriggerData`).
|
|
85
|
+
A ``setattr`` on any of them (or a hop through one) mutates state shared across sibling
|
|
86
|
+
machine instances of the same loaded class. Even if some public alias ever leaks such
|
|
87
|
+
an object as a value, this blocks pivoting the write onto a shared object
|
|
88
|
+
(GHSA-v3qq-3xvg-m77g). The datamodel container (:class:`~statemachine.model.Model`, the
|
|
89
|
+
legitimate ``machine.model`` root) and plain data (dict/list/str/int/ordinary app model
|
|
90
|
+
instances) are unaffected. Lazy imports keep this off the module import cycle. Raised as
|
|
91
|
+
``ValueError`` so the engine routes it to ``error.execution``.
|
|
92
|
+
"""
|
|
93
|
+
from ..event_data import EventData
|
|
94
|
+
from ..event_data import TriggerData
|
|
95
|
+
from ..state import State
|
|
96
|
+
from ..transition import Transition
|
|
97
|
+
from .interpreter import Interpreter
|
|
98
|
+
from .system_variables import EventDataWrapper
|
|
99
|
+
from .system_variables import IOProcessor
|
|
100
|
+
|
|
101
|
+
engine_types = (
|
|
102
|
+
StateChart,
|
|
103
|
+
Interpreter,
|
|
104
|
+
State,
|
|
105
|
+
Transition,
|
|
106
|
+
Event,
|
|
107
|
+
EventDataWrapper,
|
|
108
|
+
IOProcessor,
|
|
109
|
+
EventData,
|
|
110
|
+
TriggerData,
|
|
111
|
+
)
|
|
112
|
+
if isinstance(obj, engine_types):
|
|
113
|
+
raise ValueError(f"{descriptor} cannot traverse or write onto an engine object: {obj!r}")
|
|
114
|
+
|
|
115
|
+
|
|
40
116
|
class ParseTime:
|
|
41
117
|
pattern = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(s|ms)")
|
|
42
118
|
"""CSS2 time literal: a number (``5``, ``1.5``, ``.5``) followed by ``s`` or ``ms``."""
|
|
@@ -164,20 +240,30 @@ class Assign(CallableAction):
|
|
|
164
240
|
value = self._expr(*args, **kwargs) # type: ignore[misc]
|
|
165
241
|
|
|
166
242
|
*path, attr = self.action.location.split(".")
|
|
243
|
+
descriptor = f"<assign> 'location' ({self.action.location!r})"
|
|
167
244
|
obj = machine.model
|
|
245
|
+
# Validate every hop (initial, intermediate and final): traversing a private/dunder
|
|
246
|
+
# object (e.g. ``__class__``) or writing a protected name is how an untrusted document
|
|
247
|
+
# escapes the datamodel, validating only the final segment left that open. A class or
|
|
248
|
+
# module (``_reject_type_or_module``) and a live engine-capability instance
|
|
249
|
+
# (``_reject_engine_capability``: machine/interpreter/State/Transition/Event and the
|
|
250
|
+
# system-variable facades) are additionally rejected as a hop or write target, so even
|
|
251
|
+
# a value that leaked such an object cannot be pivoted onto shared state
|
|
252
|
+
# (GHSA-v3qq-3xvg-m77g).
|
|
168
253
|
for p in path:
|
|
254
|
+
_ensure_writable_target(p, descriptor)
|
|
255
|
+
_reject_type_or_module(obj, descriptor)
|
|
256
|
+
_reject_engine_capability(obj, descriptor)
|
|
169
257
|
obj = getattr(obj, p)
|
|
170
258
|
|
|
171
|
-
|
|
259
|
+
_ensure_writable_target(attr, descriptor)
|
|
260
|
+
_reject_type_or_module(obj, descriptor)
|
|
261
|
+
_reject_engine_capability(obj, descriptor)
|
|
262
|
+
if not (hasattr(obj, attr) or attr in kwargs):
|
|
172
263
|
raise ValueError(
|
|
173
|
-
f"<assign> 'location' must be
|
|
264
|
+
f"<assign> 'location' must be declared before assignment, "
|
|
174
265
|
f"got: {self.action.location}"
|
|
175
266
|
)
|
|
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
267
|
setattr(obj, attr, value)
|
|
182
268
|
_debug("Assign: %s = %r", self.action.location, value)
|
|
183
269
|
|
|
@@ -242,10 +328,9 @@ def create_foreach_action_callable(action: ForeachAction, evaluator: Evaluator)
|
|
|
242
328
|
except Exception as e:
|
|
243
329
|
raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
|
|
244
330
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
331
|
+
_ensure_writable_target(action.item, "<foreach> 'item'")
|
|
332
|
+
if action.index:
|
|
333
|
+
_ensure_writable_target(action.index, "<foreach> 'index'")
|
|
249
334
|
for index, item in enumerate(array):
|
|
250
335
|
# Assign the item and optionally the index
|
|
251
336
|
setattr(machine.model, action.item, item)
|
|
@@ -354,6 +439,7 @@ def create_send_action_callable( # noqa: C901
|
|
|
354
439
|
if action.id:
|
|
355
440
|
send_id = action.id
|
|
356
441
|
elif action.idlocation:
|
|
442
|
+
_ensure_writable_target(action.idlocation, "<send> 'idlocation'")
|
|
357
443
|
send_id = uuid4().hex
|
|
358
444
|
setattr(machine.model, action.idlocation, send_id)
|
|
359
445
|
|
|
@@ -408,12 +494,33 @@ def create_script_action_callable(action: ScriptAction, evaluator: Evaluator) ->
|
|
|
408
494
|
return script_action
|
|
409
495
|
|
|
410
496
|
|
|
497
|
+
def _resolve_data_src(action: DataItem, evaluator: Evaluator) -> "str | None":
|
|
498
|
+
"""Read a ``<data src="file:…">`` reference at compile time, gated on trust.
|
|
499
|
+
|
|
500
|
+
Only the ``file`` scheme is resolved (mirroring the SCXML reader's historical
|
|
501
|
+
behaviour). Reading a local file named by an untrusted document is a confidentiality
|
|
502
|
+
risk, so the restricted evaluator rejects it; ``trusted=True`` reads it as before.
|
|
503
|
+
Runs inside the document's base directory (``loader._build`` ``_chdir``), so relative
|
|
504
|
+
paths resolve against the document.
|
|
505
|
+
"""
|
|
506
|
+
if action.src is None:
|
|
507
|
+
return None
|
|
508
|
+
src_parsed = urlparse(action.src)
|
|
509
|
+
if src_parsed.scheme != "file":
|
|
510
|
+
return None
|
|
511
|
+
evaluator.ensure_external_src_allowed(f"<data id={action.id!r} src=...>")
|
|
512
|
+
with open(src_parsed.path) as f:
|
|
513
|
+
return f.read()
|
|
514
|
+
|
|
515
|
+
|
|
411
516
|
def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callable:
|
|
517
|
+
content = action.content if action.content else _resolve_data_src(action, evaluator)
|
|
412
518
|
expr_fn = evaluator.compile_value(action.expr) if action.expr else None
|
|
413
|
-
content_fn = evaluator.compile_value(
|
|
519
|
+
content_fn = evaluator.compile_value(content) if content else None
|
|
414
520
|
|
|
415
521
|
def data_initializer(**kwargs):
|
|
416
522
|
machine: StateChart = kwargs["machine"]
|
|
523
|
+
_ensure_writable_target(action.id, "<data> 'id'")
|
|
417
524
|
|
|
418
525
|
# Check for invoke param overrides — params from parent override child defaults
|
|
419
526
|
invoke_params = getattr(machine, "_invoke_params", None)
|
|
@@ -432,7 +539,7 @@ def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callabl
|
|
|
432
539
|
try:
|
|
433
540
|
value = content_fn(**kwargs)
|
|
434
541
|
except Exception:
|
|
435
|
-
value =
|
|
542
|
+
value = content
|
|
436
543
|
else:
|
|
437
544
|
value = None
|
|
438
545
|
|
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:"))
|
statemachine/io/loader.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
:func:`load` is the simple entry point: give it a file path (format detected by
|
|
4
4
|
extension) or inline content (with an explicit ``format=``) and it returns the
|
|
5
5
|
ready-to-instantiate :class:`~statemachine.statemachine.StateChart` class. It is
|
|
6
|
-
**secure by default** — expressions are evaluated by a restricted AST-
|
|
6
|
+
**secure by default** — expressions are evaluated by a restricted AST-allowlist
|
|
7
7
|
evaluator and ``<script>`` / arbitrary Python is rejected unless ``trusted=True``.
|
|
8
8
|
|
|
9
9
|
For advanced scenarios (a document declaring several statecharts, or SCXML files
|
|
@@ -39,6 +39,20 @@ def _chdir(new_dir: Path):
|
|
|
39
39
|
os.chdir(original)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _is_existing_file(source: str) -> bool:
|
|
43
|
+
"""Whether ``source`` names an existing file, treating "too long"/invalid as not a file.
|
|
44
|
+
|
|
45
|
+
A long single-line inline document (e.g. minified JSON) is not a path, but reaches
|
|
46
|
+
:meth:`Path.is_file`, which on some platforms raises ``OSError`` (``ENAMETOOLONG``) or
|
|
47
|
+
``ValueError`` (embedded NUL) instead of returning ``False``. Swallow those so inline
|
|
48
|
+
content is never mistaken for a crash; a real, resolvable path still returns ``True``.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
return Path(source).is_file()
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
def _resolve_source(source: "str | Path", format: "str | None"):
|
|
43
57
|
"""Return ``(text, format_name, location_hint, base_dir)`` for a source.
|
|
44
58
|
|
|
@@ -48,7 +62,7 @@ def _resolve_source(source: "str | Path", format: "str | None"):
|
|
|
48
62
|
"""
|
|
49
63
|
if isinstance(source, Path):
|
|
50
64
|
path: "Path | None" = source
|
|
51
|
-
elif "\n" not in source and
|
|
65
|
+
elif "\n" not in source and _is_existing_file(source):
|
|
52
66
|
path = Path(source)
|
|
53
67
|
else:
|
|
54
68
|
path = None
|
|
@@ -140,7 +154,7 @@ def load(
|
|
|
140
154
|
format: explicit format name (``"scxml"``, ``"json"``, ``"yaml"``),
|
|
141
155
|
overriding extension detection and required for inline content.
|
|
142
156
|
trusted: when ``False`` (default), expressions are evaluated by a restricted
|
|
143
|
-
AST-
|
|
157
|
+
AST-allowlist evaluator and ``<script>`` / arbitrary Python is rejected.
|
|
144
158
|
When ``True``, expressions and scripts are evaluated as arbitrary Python.
|
|
145
159
|
validate: when ``True`` (native JSON/YAML only), validate the document against
|
|
146
160
|
the published JSON Schema before building (requires the ``[validation]``
|
|
@@ -6,7 +6,10 @@ provides a Python datamodel and loads SCXML through the same
|
|
|
6
6
|
:func:`~statemachine.io.load` facade as the native JSON/YAML formats.
|
|
7
7
|
|
|
8
8
|
Like every format, SCXML is loaded **secure by default** (``trusted=False`` rejects
|
|
9
|
-
``<script>`` and
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
``<script>`` and external ``<data src>`` / ``<invoke src>`` file references, evaluates
|
|
10
|
+
expressions with the restricted AST-allowlist evaluator, confines write targets to public model
|
|
11
|
+
attributes and magnitude-caps ``**``/``*``); pass ``trusted=True`` only for documents you
|
|
12
|
+
control. The parser also refuses ``<!DOCTYPE>``/DTD to block XML entity-expansion bombs. See the
|
|
13
|
+
security note in :mod:`statemachine.io` and the GHSA-v4jc-pm6r-3vj8, GHSA-fj3w-533r-fvf6,
|
|
14
|
+
GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc and GHSA-r8gj-366q-cgvj advisories.
|
|
12
15
|
"""
|
|
@@ -18,7 +18,7 @@ class SCXMLProcessor(Interpreter):
|
|
|
18
18
|
|
|
19
19
|
Args:
|
|
20
20
|
trusted: when ``False`` (default), datamodel expressions are evaluated by
|
|
21
|
-
a restricted AST-
|
|
21
|
+
a restricted AST-allowlist evaluator and ``<script>`` is rejected, so
|
|
22
22
|
loading a document cannot execute arbitrary code. When ``True``,
|
|
23
23
|
expressions and ``<script>`` are evaluated as arbitrary Python via
|
|
24
24
|
``eval``/``exec`` — only use it for SCXML you trust (see the package
|
statemachine/io/scxml/reader.py
CHANGED
|
@@ -3,8 +3,8 @@ import xml.etree.ElementTree as ET
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from typing import Literal
|
|
5
5
|
from typing import cast
|
|
6
|
-
from urllib.parse import urlparse
|
|
7
6
|
|
|
7
|
+
from ...exceptions import InvalidDefinition
|
|
8
8
|
from ..model import Action
|
|
9
9
|
from ..model import AssignAction
|
|
10
10
|
from ..model import CancelAction
|
|
@@ -47,8 +47,27 @@ def _parse_initial(initial_content: "str | None") -> list[str]:
|
|
|
47
47
|
return initial_content.split()
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
class _NoDTDTreeBuilder(ET.TreeBuilder):
|
|
51
|
+
"""A tree builder that refuses documents carrying a ``<!DOCTYPE>``/DTD.
|
|
52
|
+
|
|
53
|
+
Internal entity definitions (the basis of billion-laughs / quadratic-blowup
|
|
54
|
+
expansion bombs) require a DTD, so rejecting the DOCTYPE neutralizes them at
|
|
55
|
+
parse time. Expat calls this ``doctype`` hook when it meets the declaration.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def doctype(self, name, pubid, system): # noqa: D102 - expat callback
|
|
59
|
+
raise InvalidDefinition(
|
|
60
|
+
"DOCTYPE/DTD is not allowed in SCXML documents (XML entity-expansion protection)."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fromstring_no_dtd(scxml_content: str) -> ET.Element:
|
|
65
|
+
"""Parse SCXML text rejecting any DOCTYPE (see :class:`_NoDTDTreeBuilder`)."""
|
|
66
|
+
return ET.fromstring(scxml_content, parser=ET.XMLParser(target=_NoDTDTreeBuilder()))
|
|
67
|
+
|
|
68
|
+
|
|
50
69
|
def parse_scxml(scxml_content: str) -> StateMachineDefinition: # noqa: C901
|
|
51
|
-
root =
|
|
70
|
+
root = _fromstring_no_dtd(scxml_content)
|
|
52
71
|
strip_namespaces(root)
|
|
53
72
|
|
|
54
73
|
scxml = root if root.tag == "scxml" else root.find(".//scxml")
|
|
@@ -114,16 +133,12 @@ def parse_datamodel(root: ET.Element) -> "DataModel | None":
|
|
|
114
133
|
for datamodel_elem in _find_own_datamodel_elements(root):
|
|
115
134
|
for data_elem in datamodel_elem.findall("data"):
|
|
116
135
|
content = data_elem.text and re.sub(r"\s+", " ", data_elem.text).strip() or None
|
|
117
|
-
src
|
|
118
|
-
|
|
119
|
-
if src_parsed and src_parsed.scheme == "file" and content is None:
|
|
120
|
-
with open(src_parsed.path) as f:
|
|
121
|
-
content = f.read()
|
|
122
|
-
|
|
136
|
+
# An external ``src`` is kept as a raw string; resolving it (reading the file)
|
|
137
|
+
# is deferred to datamodel compilation, where the trust policy can gate it.
|
|
123
138
|
data_model.data.append(
|
|
124
139
|
DataItem(
|
|
125
140
|
id=data_elem.attrib["id"],
|
|
126
|
-
src=src,
|
|
141
|
+
src=data_elem.attrib.get("src"),
|
|
127
142
|
expr=data_elem.attrib.get("expr"),
|
|
128
143
|
content=content,
|
|
129
144
|
)
|
|
@@ -18,13 +18,23 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
@dataclass
|
|
20
20
|
class _Data:
|
|
21
|
-
|
|
21
|
+
"""The ``_event.data`` payload view.
|
|
22
|
+
|
|
23
|
+
The raw payload dict is stored under a private (``_``-prefixed) name so a restricted
|
|
24
|
+
expression cannot hand back the underlying dict via ``_event.data.kwargs``; only the
|
|
25
|
+
individual payload fields (``_event.data.<field>``) are reachable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_kwargs: dict
|
|
22
29
|
|
|
23
30
|
def __getattr__(self, name):
|
|
24
|
-
|
|
31
|
+
if name == "_kwargs":
|
|
32
|
+
# Guard against unbounded recursion before the field is set (e.g. copy/pickle).
|
|
33
|
+
raise AttributeError(name)
|
|
34
|
+
return self._kwargs.get(name, None)
|
|
25
35
|
|
|
26
36
|
def get(self, name, default=None):
|
|
27
|
-
return self.
|
|
37
|
+
return self._kwargs.get(name, default)
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
class OriginTypeSCXML(str):
|
|
@@ -51,15 +61,21 @@ class EventDataWrapper:
|
|
|
51
61
|
"""
|
|
52
62
|
|
|
53
63
|
def __init__(self, event_data=None, *, trigger_data=None):
|
|
54
|
-
|
|
64
|
+
# The engine carriers are stored under private (``_``-prefixed) names so a
|
|
65
|
+
# restricted expression cannot reach them (``_event.event_data`` /
|
|
66
|
+
# ``_event.trigger_data``) and pivot to the machine/interpreter/State. The
|
|
67
|
+
# restricted evaluator blocks attribute access to ``_``-prefixed names, and this
|
|
68
|
+
# class deliberately exposes NO public attribute or ``__getattr__`` forward that
|
|
69
|
+
# would hand them back (GHSA-v3qq-3xvg-m77g).
|
|
70
|
+
self._event_data = event_data
|
|
55
71
|
if trigger_data is not None:
|
|
56
|
-
self.
|
|
72
|
+
self._trigger_data = trigger_data
|
|
57
73
|
elif event_data is not None:
|
|
58
|
-
self.
|
|
74
|
+
self._trigger_data = event_data.trigger_data
|
|
59
75
|
else:
|
|
60
76
|
raise ValueError("Either event_data or trigger_data must be provided")
|
|
61
77
|
|
|
62
|
-
td = self.
|
|
78
|
+
td = self._trigger_data
|
|
63
79
|
self.sendid = td.send_id
|
|
64
80
|
self.invokeid = td.kwargs.get("_invokeid", "")
|
|
65
81
|
if td.event is None or td.event.internal:
|
|
@@ -76,25 +92,20 @@ class EventDataWrapper:
|
|
|
76
92
|
"""Create an EventDataWrapper directly from a TriggerData (no EventData needed)."""
|
|
77
93
|
return cls(trigger_data=trigger_data)
|
|
78
94
|
|
|
79
|
-
def __getattr__(self, name):
|
|
80
|
-
if self.event_data is not None:
|
|
81
|
-
return getattr(self.event_data, name)
|
|
82
|
-
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
83
|
-
|
|
84
95
|
def __eq__(self, value):
|
|
85
96
|
"This makes SCXML test 329 pass. It assumes that the event is the same instance"
|
|
86
97
|
return isinstance(value, EventDataWrapper)
|
|
87
98
|
|
|
88
99
|
@property
|
|
89
100
|
def name(self):
|
|
90
|
-
if self.
|
|
91
|
-
return self.
|
|
92
|
-
return str(self.
|
|
101
|
+
if self._event_data is not None:
|
|
102
|
+
return self._event_data.event
|
|
103
|
+
return str(self._trigger_data.event) if self._trigger_data.event else None
|
|
93
104
|
|
|
94
105
|
@property
|
|
95
106
|
def data(self):
|
|
96
107
|
"Property used to access the event payload (the SCXML ``_event.data``)."
|
|
97
|
-
td = self.
|
|
108
|
+
td = self._trigger_data
|
|
98
109
|
if td.kwargs:
|
|
99
110
|
return _Data(td.kwargs)
|
|
100
111
|
elif td.args and len(td.args) == 1:
|
|
@@ -113,15 +124,19 @@ class IOProcessor:
|
|
|
113
124
|
"""
|
|
114
125
|
|
|
115
126
|
def __init__(self, interpreter, machine):
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
# Stored under private (``_``-prefixed) names so a restricted expression cannot
|
|
128
|
+
# reach the interpreter/machine via ``_ioprocessors.interpreter`` /
|
|
129
|
+
# ``_ioprocessors.machine`` and mutate the process-shared engine
|
|
130
|
+
# (GHSA-v3qq-3xvg-m77g). Only the SCXML-visible ``location`` stays public.
|
|
131
|
+
self._interpreter = interpreter
|
|
132
|
+
self._machine = machine
|
|
118
133
|
|
|
119
134
|
def __getitem__(self, name: str):
|
|
120
135
|
return self
|
|
121
136
|
|
|
122
137
|
@property
|
|
123
138
|
def location(self):
|
|
124
|
-
return self.
|
|
139
|
+
return self._machine.name
|
|
125
140
|
|
|
126
141
|
def get(self, name: str):
|
|
127
142
|
return getattr(self, name)
|
statemachine/orderedset.py
CHANGED
|
@@ -62,10 +62,14 @@ class OrderedSet(MutableSet[T]):
|
|
|
62
62
|
>>> s[2]
|
|
63
63
|
3
|
|
64
64
|
|
|
65
|
-
>>>
|
|
66
|
-
|
|
65
|
+
>>> s[-1]
|
|
66
|
+
3
|
|
67
67
|
|
|
68
|
+
>>> s[-3]
|
|
69
|
+
1
|
|
68
70
|
|
|
71
|
+
>>> eval(repr(OrderedSet(['a', 'b', 'c'])))
|
|
72
|
+
OrderedSet(['a', 'b', 'c'])
|
|
69
73
|
|
|
70
74
|
"""
|
|
71
75
|
|
|
@@ -84,10 +88,15 @@ class OrderedSet(MutableSet[T]):
|
|
|
84
88
|
self._d.pop(x, None)
|
|
85
89
|
|
|
86
90
|
def __getitem__(self, index) -> T:
|
|
91
|
+
original_index = index
|
|
92
|
+
if index < 0:
|
|
93
|
+
index += len(self)
|
|
94
|
+
if index < 0:
|
|
95
|
+
raise IndexError(f"index {original_index} out of range")
|
|
87
96
|
try:
|
|
88
97
|
return next(itertools.islice(self._d, index, index + 1))
|
|
89
98
|
except StopIteration as e:
|
|
90
|
-
raise IndexError(f"index {
|
|
99
|
+
raise IndexError(f"index {original_index} out of range") from e
|
|
91
100
|
|
|
92
101
|
def __contains__(self, x: object) -> bool:
|
|
93
102
|
return self._d.__contains__(x)
|
statemachine/spec_parser.py
CHANGED
|
@@ -19,6 +19,15 @@ comparison_repr = {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class UnsupportedExpression(ValueError):
|
|
23
|
+
"""The expression contains a structure that is not on the parser allowlist.
|
|
24
|
+
|
|
25
|
+
Distinguishes a rejected expression from errors raised by the ``variable_hook``
|
|
26
|
+
while resolving names, so callers can report each one properly. Inherits from
|
|
27
|
+
``ValueError`` to keep the previous behavior for callers catching it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
22
31
|
def _unique_key(left, right, operator) -> str:
|
|
23
32
|
left_key = getattr(left, "unique_key", "")
|
|
24
33
|
right_key = getattr(right, "unique_key", "")
|
|
@@ -128,7 +137,7 @@ class Functions:
|
|
|
128
137
|
def get(cls, func_id):
|
|
129
138
|
func_id = func_id.lower()
|
|
130
139
|
if func_id not in cls.registry:
|
|
131
|
-
raise
|
|
140
|
+
raise UnsupportedExpression(f"Unsupported function: {func_id}")
|
|
132
141
|
return cls.registry[func_id]
|
|
133
142
|
|
|
134
143
|
|
|
@@ -231,7 +240,7 @@ def build_attribute(value_expr: Callable, attr: str) -> Callable:
|
|
|
231
240
|
def build_expression( # noqa: C901
|
|
232
241
|
node, variable_hook, operator_mapping, allow_value_nodes: bool = False
|
|
233
242
|
):
|
|
234
|
-
"""Build a callable from an AST node using
|
|
243
|
+
"""Build a callable from an AST node using an allowlist of allowed structures.
|
|
235
244
|
|
|
236
245
|
Args:
|
|
237
246
|
allow_value_nodes: when ``True``, value-producing structures (arithmetic,
|
|
@@ -265,7 +274,7 @@ def build_expression( # noqa: C901
|
|
|
265
274
|
expressions.append(expression)
|
|
266
275
|
return reduce(custom_and, expressions)
|
|
267
276
|
case ast.Call(func=ast.Name(id=func_id)):
|
|
268
|
-
# Only
|
|
277
|
+
# Only allowlisted functions from the registry (e.g. ``In(...)``) are
|
|
269
278
|
# callable. Method calls (``obj.method()``) have an ``ast.Attribute``
|
|
270
279
|
# func and fall through to the ``case _`` guard below — this prevents
|
|
271
280
|
# using calls as a sandbox-escape vector.
|
|
@@ -276,10 +285,13 @@ def build_expression( # noqa: C901
|
|
|
276
285
|
return operator_mapping[type(node.op)](recurse(node.operand))
|
|
277
286
|
case ast.UnaryOp(op=(ast.USub() | ast.UAdd())) if allow_value_nodes:
|
|
278
287
|
return build_unaryop(unary_operators[type(node.op)], recurse(node.operand))
|
|
279
|
-
case ast.BinOp() if allow_value_nodes
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
288
|
+
case ast.BinOp() if allow_value_nodes:
|
|
289
|
+
op_type = type(node.op)
|
|
290
|
+
if op_type not in binary_operators:
|
|
291
|
+
# e.g. bitwise ``^``/``|``/``<<`` are outside the allowlist. (``**`` and ``*``
|
|
292
|
+
# are allowed but magnitude-capped — see ``binary_operators``.)
|
|
293
|
+
raise ValueError(f"Binary operator '{op_type.__name__}' is not allowed")
|
|
294
|
+
return build_binop(binary_operators[op_type], recurse(node.left), recurse(node.right))
|
|
283
295
|
case ast.List(elts=elts) if allow_value_nodes:
|
|
284
296
|
return build_collection(list, [recurse(e) for e in elts])
|
|
285
297
|
case ast.Tuple(elts=elts) if allow_value_nodes:
|
|
@@ -300,14 +312,16 @@ def build_expression( # noqa: C901
|
|
|
300
312
|
# without underscore-attribute access or method calls, it cannot reach
|
|
301
313
|
# type objects.
|
|
302
314
|
if attr.startswith("_"):
|
|
303
|
-
raise
|
|
315
|
+
raise UnsupportedExpression(f"Attribute access to '{attr}' is not allowed")
|
|
304
316
|
return build_attribute(recurse(node.value), attr)
|
|
305
317
|
case ast.Name(id=name):
|
|
306
318
|
return variable_hook(name)
|
|
307
319
|
case ast.Constant(value=value):
|
|
308
320
|
return build_constant(value)
|
|
309
321
|
case _:
|
|
310
|
-
raise
|
|
322
|
+
raise UnsupportedExpression(
|
|
323
|
+
f"Unsupported expression structure: {node.__class__.__name__}"
|
|
324
|
+
)
|
|
311
325
|
|
|
312
326
|
|
|
313
327
|
def parse_boolean_expr(expr, variable_hook, operator_mapping):
|
|
@@ -315,8 +329,10 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
|
|
|
315
329
|
if expr.strip() == "":
|
|
316
330
|
raise SyntaxError("Empty expression")
|
|
317
331
|
|
|
318
|
-
# Optimization
|
|
319
|
-
|
|
332
|
+
# Optimization: a lone identifier can only be a variable name, so there is
|
|
333
|
+
# nothing to parse. Anything else (operators, comparisons, spaces, calls)
|
|
334
|
+
# goes through the parser.
|
|
335
|
+
if expr.isidentifier():
|
|
320
336
|
return variable_hook(expr)
|
|
321
337
|
expr = replace_operators(expr)
|
|
322
338
|
tree = ast.parse(expr, mode="eval")
|
|
@@ -324,7 +340,7 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
|
|
|
324
340
|
|
|
325
341
|
|
|
326
342
|
def parse_expr(expr: str, variable_hook: Callable) -> Callable:
|
|
327
|
-
"""Parse a value expression into a callable using the restricted AST
|
|
343
|
+
"""Parse a value expression into a callable using the restricted AST allowlist.
|
|
328
344
|
|
|
329
345
|
Unlike :func:`parse_boolean_expr`, this does not apply the DSL operator
|
|
330
346
|
replacement (``!``/``^``/``v``) and does not coerce the top-level result to
|
|
@@ -333,7 +349,7 @@ def parse_expr(expr: str, variable_hook: Callable) -> Callable:
|
|
|
333
349
|
without :func:`eval`.
|
|
334
350
|
|
|
335
351
|
Raises:
|
|
336
|
-
ValueError: if the expression uses a structure outside the
|
|
352
|
+
ValueError: if the expression uses a structure outside the allowlist
|
|
337
353
|
(e.g. attribute access to dunders, method calls, lambdas).
|
|
338
354
|
SyntaxError: if the expression is empty or not valid Python.
|
|
339
355
|
"""
|
|
@@ -358,14 +374,52 @@ operator_mapping = {
|
|
|
358
374
|
ast.NotEq: build_custom_operator(operator.ne),
|
|
359
375
|
}
|
|
360
376
|
|
|
377
|
+
# Result-size caps for the two operators that can blow up cheaply. Ordinary scalar
|
|
378
|
+
# arithmetic (``x * 2``, ``x ** 2``) is well under these; the caps only reject the
|
|
379
|
+
# denial-of-service forms (``9**9**9`` bignum, ``[0]*20000000`` sequence replication).
|
|
380
|
+
_MAX_POW_RESULT_BITS = 4096
|
|
381
|
+
_MAX_SEQUENCE_LEN = 1_000_000
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _guarded_pow(base, exp):
|
|
385
|
+
"""``**`` with a magnitude cap (GHSA-r8gj-366q-cgvj).
|
|
386
|
+
|
|
387
|
+
``int ** int`` can allocate a giant bignum from a tiny expression (``9**9**9`` is a
|
|
388
|
+
~370-million-digit number). The result size is estimated *before* computing, so the
|
|
389
|
+
allocation never happens. Non-integer operands (floats overflow to ``inf`` instead of
|
|
390
|
+
growing without bound) are passed straight through.
|
|
391
|
+
"""
|
|
392
|
+
if isinstance(base, int) and isinstance(exp, int) and exp > 0 and base not in (0, 1, -1):
|
|
393
|
+
if base.bit_length() * exp > _MAX_POW_RESULT_BITS:
|
|
394
|
+
raise ValueError("'**' result is too large for the restricted evaluator")
|
|
395
|
+
return operator.pow(base, exp)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _guarded_mul(a, b):
|
|
399
|
+
"""``*`` with a sequence-replication cap (GHSA-r8gj-366q-cgvj).
|
|
400
|
+
|
|
401
|
+
``seq * n`` (list/str/bytes/tuple times an int) can allocate an enormous object from a
|
|
402
|
+
12-character expression (``[0]*20000000``). The resulting length is checked *before*
|
|
403
|
+
allocating. Scalar numeric multiplication is unaffected.
|
|
404
|
+
"""
|
|
405
|
+
for seq, n in ((a, b), (b, a)):
|
|
406
|
+
if isinstance(seq, (str, bytes, bytearray, list, tuple)) and isinstance(n, int):
|
|
407
|
+
if n > 0 and n * len(seq) > _MAX_SEQUENCE_LEN:
|
|
408
|
+
raise ValueError("'*' repetition is too large for the restricted evaluator")
|
|
409
|
+
return operator.mul(a, b)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ``**`` and ``*`` stay available for ordinary arithmetic but are wrapped so a tiny untrusted
|
|
413
|
+
# expression cannot exhaust CPU/memory (GHSA-r8gj-366q-cgvj). ``trusted=True`` uses the full
|
|
414
|
+
# Python evaluator instead, without these caps.
|
|
361
415
|
binary_operators = {
|
|
362
416
|
ast.Add: operator.add,
|
|
363
417
|
ast.Sub: operator.sub,
|
|
364
|
-
ast.Mult:
|
|
418
|
+
ast.Mult: _guarded_mul,
|
|
365
419
|
ast.Div: operator.truediv,
|
|
366
420
|
ast.FloorDiv: operator.floordiv,
|
|
367
421
|
ast.Mod: operator.mod,
|
|
368
|
-
ast.Pow:
|
|
422
|
+
ast.Pow: _guarded_pow,
|
|
369
423
|
}
|
|
370
424
|
|
|
371
425
|
unary_operators = {
|
statemachine/state.py
CHANGED
|
@@ -296,7 +296,7 @@ class State:
|
|
|
296
296
|
)
|
|
297
297
|
|
|
298
298
|
def __str__(self):
|
|
299
|
-
return self.name
|
|
299
|
+
return str(self.name)
|
|
300
300
|
|
|
301
301
|
@property
|
|
302
302
|
def id(self) -> str:
|
|
@@ -308,7 +308,7 @@ class State:
|
|
|
308
308
|
self.value = id
|
|
309
309
|
if not self.name:
|
|
310
310
|
self.name = humanize_id(self._id)
|
|
311
|
-
self._hash = hash((self.name, self._id))
|
|
311
|
+
self._hash = hash((str(self.name), self._id))
|
|
312
312
|
|
|
313
313
|
return self
|
|
314
314
|
|
{python_statemachine-3.2.0.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|