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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-statemachine
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Python Finite State Machines made easy.
5
5
  Project-URL: homepage, https://github.com/fgmacedo/python-statemachine
6
6
  Author-email: Fernando Macedo <fgmacedo@gmail.com>
@@ -1,23 +1,23 @@
1
- statemachine/__init__.py,sha256=QiHL_KGtaPxEuPtKCpwb6DssApC1jcalKYrkfNgMKc4,445
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=DyRqwgr7gXom312BIPsNZiueq5n8jNp1dBDExm7mG_g,8109
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=N6iTrin-uMyw31mbXbO6FUj0V5WGvfKaNZe6L2pV0IE,1314
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=7y4Hu6vVD0LHTSJ5iTB9F-faRw0326EubR3AOrpZAHE,2570
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=fjiUjtzTAk6UliNsdbeInqrnp38cfHeYVlgMrbqrzxc,13450
20
- statemachine/state.py,sha256=kSHeJh1BlSpeDPxMKCCpwIJqzx4MPdXYZ1S24kki4os,15426
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=oNy67KToH7zxgEIPavOvgbpoYTSkM3iWB-Zas1_lcqY,9854
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=_n7x5IDu6bucBjE7PFdHBzYwrEcPd-iRxDfMc4FE9HI,22537
42
- statemachine/engines/base.py,sha256=kAgpDu8iTejPBXp_4casK_rQuHkL_G3Njph8iuXzDiw,37954
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=i5rGVI1ETu1hH0W8qtQMqK0WrFCYLPdV-s5VXh9vOSo,2062
45
- statemachine/io/actions.py,sha256=FmRjPFJkcQwtxSEaOXbvu3S5vETai2j5r8mC_4eZ8Og,18624
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=tMcovIr6dhysLRaxUjYbNmPu3jvOzpHOZDjevUnGEMU,10640
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=wbeSJSlDLEvS7hXSznpMT7wd8GDcVsQSXGgFr67zVrI,9877
51
- statemachine/io/loader.py,sha256=GbCF0MoLxcRa84zZOZPsJpdIUeodFGcsFoWE_Wk4bqQ,6442
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=7skuJgBL8giqzaNEyWd783xOZtVRSUHF6ETwt9fVcd4,6223
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=iGS763p9fZ6A7NVuEVpDVOS303q0dr5N4kVfOZ37jsk,660
61
- statemachine/io/scxml/processor.py,sha256=Ru_YQAA1OVXNS9AqfN2myL5POq-C4kI1Vpef-eO7k2M,1532
62
- statemachine/io/scxml/reader.py,sha256=CNUI9ARDJswRZDKqJSBY98Uoa5rQJsIU-kS9Uu5cmzg,17057
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.0.dist-info/METADATA,sha256=Vdy46mBCvmA04ruIBaTS9XklmK72ip3yycmrzgCWu48,12642
70
- python_statemachine-3.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
71
- python_statemachine-3.2.0.dist-info/licenses/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
72
- python_statemachine-3.2.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
statemachine/__init__.py CHANGED
@@ -8,7 +8,7 @@ from .statemachine import TModel
8
8
 
9
9
  __author__ = """Fernando Macedo"""
10
10
  __email__ = "fgmacedo@gmail.com"
11
- __version__ = "3.2.0"
11
+ __version__ = "3.2.1"
12
12
 
13
13
  __all__ = [
14
14
  "StateChart",
@@ -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,
@@ -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
@@ -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, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
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(info.transition, trigger_data)
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)
@@ -419,19 +419,30 @@ class BaseEngine:
419
419
  return result
420
420
 
421
421
  def _get_args_kwargs(
422
- self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
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
@@ -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([s.name for s in configuration])
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
  )
@@ -11,19 +11,26 @@ whether it was authored in SCXML, JSON or YAML.
11
11
  Security
12
12
  --------
13
13
 
14
- Loading a document compiles its guards, datamodel expressions and executable content into
15
- callables. Because a document may come from a semi-trusted source, loading is **secure by
16
- default** (``trusted=False``): expressions are evaluated by a **restricted AST-whitelist
17
- evaluator** that cannot reach builtins, dunder attributes, or arbitrary calls, and
18
- ``<script>`` / ``script`` (arbitrary code) is rejected. This mirrors ``yaml.safe_load`` and
19
- keeps loading from turning into arbitrary code execution.
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 and enables ``script``.
22
- In that mode a document is equivalent to executable Python (much like :mod:`pickle`), so
23
- **only load ``trusted`` documents from sources you control** (hand-authored documents, the
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
- See the GHSA-v4jc-pm6r-3vj8 advisory for details.
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
@@ -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
- if not attr.isidentifier() or not (hasattr(obj, attr) or attr in kwargs):
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 a valid Python attribute name and must be declared, "
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
- if not action.item.isidentifier():
246
- raise ValueError(
247
- f"<foreach> 'item' must be a valid Python attribute name, got: {action.item}"
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(action.content) if action.content else None
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 = action.content
542
+ value = content
436
543
  else:
437
544
  value = None
438
545
 
@@ -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-whitelist evaluator (:func:`statemachine.spec_parser.parse_expr`), which
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
- advisory for the rationale.
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 (engine-provided variables such as
86
- ``_event``, ``_name``, ``machine``), then falls back to a (non-protected, non-private)
87
- attribute of ``machine.model``. Mirrors the namespace built by :func:`_eval`.
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-whitelist evaluation, no ``eval``/``exec``.
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 whitelist rejects —
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-whitelist
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 Path(source).is_file():
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-whitelist evaluator and ``<script>`` / arbitrary Python is rejected.
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 evaluates expressions with the restricted AST-whitelist evaluator); pass
10
- ``trusted=True`` only for documents you control. See the security note in
11
- :mod:`statemachine.io` and the GHSA-v4jc-pm6r-3vj8 advisory.
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-whitelist evaluator and ``<script>`` is rejected, so
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
@@ -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 = ET.fromstring(scxml_content)
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 = data_elem.attrib.get("src")
118
- src_parsed = urlparse(src) if src else None
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
- kwargs: dict
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
- return self.kwargs.get(name, None)
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.kwargs.get(name, default)
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
- self.event_data = event_data
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.trigger_data = trigger_data
72
+ self._trigger_data = trigger_data
57
73
  elif event_data is not None:
58
- self.trigger_data = event_data.trigger_data
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.trigger_data
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.event_data is not None:
91
- return self.event_data.event
92
- return str(self.trigger_data.event) if self.trigger_data.event else None
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.trigger_data
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
- self.interpreter = interpreter
117
- self.machine = machine
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.machine.name
139
+ return self._machine.name
125
140
 
126
141
  def get(self, name: str):
127
142
  return getattr(self, name)
@@ -62,10 +62,14 @@ class OrderedSet(MutableSet[T]):
62
62
  >>> s[2]
63
63
  3
64
64
 
65
- >>> eval(repr(OrderedSet(['a', 'b', 'c'])))
66
- OrderedSet(['a', 'b', 'c'])
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 {index} out of range") from e
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)
@@ -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 ValueError(f"Unsupported function: {func_id}")
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 a whitelist of allowed structures.
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 whitelisted functions from the registry (e.g. ``In(...)``) are
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 and type(node.op) in binary_operators:
280
- return build_binop(
281
- binary_operators[type(node.op)], recurse(node.left), recurse(node.right)
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 ValueError(f"Attribute access to '{attr}' is not allowed")
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 ValueError(f"Unsupported expression structure: {node.__class__.__name__}")
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 trying to avoid parsing the expression if not needed
319
- if "!" not in expr and " " not in expr and "In(" not in expr:
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 whitelist.
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 whitelist
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: operator.mul,
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: operator.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