Skip to content
Merged

Dev #52

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.26] - 2026-09-29

### Security
- **Remote code execution through msgpack deserialization.** `msgpack_numpy`'s
decoder calls `pickle.loads` on object-dtype (`kind == 'O'`) numpy arrays, and
`m.patch()` installs it as the global msgpack `object_hook`. So any
`msgpack.unpackb` in the process — including deserialization of an untrusted
payload such as an uploaded `.ocd` design — ran `pickle.loads`, which executes
arbitrary code, before any class-allowlist check. The patched reader also
wraps a caller-supplied `object_hook` and runs `decode` first, so a hook
cannot make unpack safe. Fixed by capturing the unpatched `msgpack.unpackb`
before `m.patch()` and re-overriding `msgpack.unpackb`/`loads` with a safe
reader that refuses object-dtype arrays (`UnsafeObjectArrayError`) and
delegates every other map to the normal decoder. Writing (`packb`) is
unchanged. No cell design uses object-dtype arrays.

## [0.2.25] - 2026-09-29

### Added
- **`SerializerMixin` stores classes as references.** A class value is saved
as `{'__type__': 'module.Class'}` and resolved through the allowlist;
previously it became `None` with no error. A class, an enum or a tuple can
now be a dict key.
- `frozenset` round-trips (`__frozenset__`). Set items are now written in
sorted order, so equal sets give equal bytes. The 0.2.24 `__set__` payload
shape is unchanged.
- `register_class_alias(old_path, new_path)` loads old payloads after a class
moves or is renamed. Aliases may chain; cycles are refused; the allowlist
check applies to the new path.
- `_schema_version` / `_migrate(data, version)` on serializable classes. A
payload older than the class is migrated on load; a newer one raises
`SchemaVersionError`. Version 0 is not written, so existing payloads are
byte-identical.
- A missing module or class raises `MissingClassError` naming the path.
Both new errors subclass `ValueError`.

### Changed
- `PropagationMixin` no longer calls `_set_parent` on a class value after a
load.

## [0.2.24] - 2026-09-18

### Added
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ requires-python = ">=3.10"
license = {text = "AGPL-3.0-or-later"}
authors = [
{name = "Nicholas Siemons", email = "nsiemons@stanford.edu"},
{name = "Antoine Breton", email = "abreton@stanford.edu"},
]
maintainers = [
{name = "Nicholas Siemons", email = "nsiemons@stanford.edu"},
Expand Down
19 changes: 14 additions & 5 deletions steer_core/Mixins/Propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ def wrapper(self, new_value):
return decorator


def _has_parent(value: Any) -> bool:
"""Return True if ``value`` is an instance that can take a parent reference.

A class stored as a value (a type reference) also has ``_set_parent``, as an
unbound function. It must not be called.
"""
return hasattr(value, '_set_parent') and not isinstance(value, type)


class PropagationMixin:
"""
Mixin providing update propagation through a hierarchical object tree.
Expand Down Expand Up @@ -320,21 +329,21 @@ def _set_parent_on_value(self, value: Any, attr_name: Optional[str] = None) -> N
attr_name: The attribute name on this object that holds the value.
Used for setter-based propagation.
"""
if hasattr(value, '_set_parent'):
if _has_parent(value):
value._set_parent(self, attr_name)
elif isinstance(value, (list, tuple)):
elif isinstance(value, (list, tuple, set, frozenset)):
# For collections, items don't have a direct setter path
for item in value:
if hasattr(item, '_set_parent'):
if _has_parent(item):
item._set_parent(self, attr_name)
elif isinstance(value, dict):
# Check both keys AND values - materials can be dict keys
# For dict items, they don't have a direct setter path
for item in value.keys():
if hasattr(item, '_set_parent'):
if _has_parent(item):
item._set_parent(self, attr_name)
for item in value.values():
if hasattr(item, '_set_parent'):
if _has_parent(item):
item._set_parent(self, attr_name)

def __deepcopy__(self, memo):
Expand Down
Loading
Loading