Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 3 additions & 1 deletion .github/workflows/sanitizers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ jobs:
test_DCSecurityAnlysis \
test_SecurityAnlysis_cpp \
test_ContingencyAnalysis_limit_violations \
test_ContingencyAnalysis_split
test_ContingencyAnalysis_split \
test_check_grid \
test_LSGrid_out_of_bounds

debug_asserts:
# Release wheels are compiled with NDEBUG, which silences every Eigen
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding

[0.14.0] 2026-xx-yy
---------------------
- [ADDED] ``GridModel.check_grid()`` (C++ ``LSGrid::check_grid()``): a whole-grid
consistency check that verifies every index the grid carries (element bus ids,
substation ids, position in the topology vector, generator slack and
remote-regulated bus references) is in range. It raises ``IndexError`` /
``RuntimeError`` on an inconsistency.
- [ADDED] the grid is now validated automatically with ``check_grid()`` when it is
loaded (from a pickle or the binary format, via ``set_state``) and by every grid
loader (``init_from_pandapower`` / ``init_from_pypowsybl`` / ``init_from_matpower``
/ ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an
out-of-range bus id in a crafted binary file) now raises a clean exception instead
of causing an out-of-bounds access during the next powerflow.
- [ADDED] a sanity check on the voltages returned by a (possibly plugin / custom)
solver before they are consumed: a wrong-sized result raises, and a non-finite one
is reported as a non-converged solve instead of propagating ``NaN`` / ``Inf``.
- [ADDED] a "Security" documentation page describing the trust boundaries (pickle and
solver plugins are trusted-input-only; the binary format is hardened against
untrusted input and validated with ``check_grid`` on load).
- [BREAKING] For plugin developers (C++ side): solver plugins no longer self-register
from a static ``AlgorithmRegistrar`` constructor firing during ``dlopen``. Instead a
plugin writes a ``void(ls2g::PluginRegistrar&)`` registration function and exposes it
Expand Down
16 changes: 11 additions & 5 deletions docs/binary_serialization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ Individual element containers work the same way:
corrupted internal sizes (every count in the file is checked against the real file size *before*
anything is allocated), files that contain an object of a different type (*eg* loading a
``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing
bytes are all rejected. ``save_binary`` on the other hand is **atomic by default**: it writes to a
bytes are all rejected. On top of that byte-level validation, loading a whole grid also runs
:py:meth:`LSGrid.check_grid` (the same consistency check used everywhere a grid is loaded): a file
that is byte-wise well-formed but carries an out-of-range index (bus / substation /
topology-vector) is rejected with an ``IndexError`` (out-of-range index) or a ``RuntimeError``
(structural inconsistency) rather than being loaded into a state that would fault on the next
powerflow. ``save_binary`` on the other hand is **atomic by default**: it writes to a
temporary file that only replaces the destination once fully written, so an interrupted save never
destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead
(marginally faster -- it skips one temporary file and rename -- but without that protection).
Expand All @@ -70,10 +75,11 @@ Individual element containers work the same way:
rules to follow:

- **Never load a binary file from a source you do not trust.** ``load_binary``
rejects structurally ill-formed input (see above), but it cannot tell a
legitimate grid from a maliciously crafted one that happens to be well-formed --
loading it still means trusting its content outright, the same way loading a
pickle file does.
rejects structurally ill-formed input and, via ``check_grid``, a grid with
out-of-range indices (see above), but a well-formed, internally-consistent
file can still carry adversarial *values* -- loading it
means trusting its content, the same way loading a pickle file does. See also
:ref:`security`.
- **Generate the file on the machine that will consume it, with the same
lightsim2grid version.** The format stores raw native data (same endianness,
``real_type``, ...) with no checksum and no cross-platform migration; the
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ This is a work in progress at the moment
cpp_library
solver_plugin
binary_serialization
security


Indices and tables
Expand Down
107 changes: 107 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
.. _security:

Security and trust boundaries
=============================

This page describes what lightsim2grid does, and does **not**, protect you from
when you load a grid or a solver that you did not produce yourself. It is meant
to help you decide which inputs are safe to accept from a third party.

The short version
-----------------

.. list-table::
:header-rows: 1
:widths: 30 70

* - Channel
- Trust required
* - Unpickling a grid (``pickle.load`` / ``joblib`` / ...)
- **Trusted input only.** Loading a pickle can execute arbitrary Python
code *by design* — this is a property of Python's ``pickle`` module, not
of lightsim2grid. Never unpickle a grid (or anything else) that came from
an untrusted source.
* - Loading a solver plugin (:func:`lightsim2grid.load_algorithm_plugin`)
- **Trusted code only.** A plugin is a native shared library that runs
in-process; once loaded it can do anything the host process can. Only
load plugins you built or otherwise trust.
* - Loading a grid from the fast binary format (``save_binary`` /
``load_binary``)
- **Hardened against untrusted input.** See below.
* - Building a grid from a source file (pandapower, pypowsybl, matpower,
powermodels)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also add (pandapower, pypowsybl, matpower, power models, including grid2op environment)

- **Validated on load.** See below.

Pickle and plugins are trusted-input-only by design
---------------------------------------------------

Unpickling arbitrary data and loading a native plugin are both, fundamentally,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unpickling arbitrary data, loading native plugin or using a grid2op environment are all fundamentally etc

ways of running code chosen by whoever produced the input. No amount of
validation inside lightsim2grid changes that, so the only safe rule is: **do not
load a pickle or a plugin from a source you do not trust.** This is the same

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add "load a pickle or plugin or call grid2op.make"

posture as ``numpy``, ``pandas`` (``read_pickle``), ``torch.load`` and any other
library that supports pickling.

The version string embedded in a pickled grid is a *compatibility* check (it
refuses to load a grid saved by a different lightsim2grid version); it is **not**
a security control.

The binary format is the channel to use for untrusted data

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer a terminology like "least dangerous", I don't want people to think it's a good idea to load untrusted data (regardless of the protection made here)

----------------------------------------------------------

The fast binary format (:ref:`binary-serialization`) was designed as a safe,
additive alternative to pickle. Loading a binary file never executes code from
the file, and it is validated at two levels:

* **Byte level.** The reader checks the file's magic number, format version and
object type, validates every length field against the actual file size
*before* allocating (so a corrupted size cannot trigger a huge allocation),
and requires the file to be fully consumed. Malformed, truncated or corrupted
bytes raise ``RuntimeError``.
* **Semantic level.** After the bytes are read back into a grid, ``check_grid()``
runs automatically (see below): a file that is byte-wise well-formed but
*inconsistent* — for example one with an out-of-range bus id — raises a clean
exception instead of leaving the grid in a state that would cause an
out-of-bounds memory access on the next powerflow.

``check_grid()``: whole-grid consistency validation
---------------------------------------------------

:py:meth:`LSGrid.check_grid` (also available as ``GridModel.check_grid``) verifies that a grid

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gridmodel is now called LSGrid. It is deprecated

is internally consistent and safe to run a powerflow on. It checks that every
index the grid carries is in range — the bus id of every element, the substation
id and the position in the topology vector (both optional), and the generator
slack and remote-regulated bus references. It raises ``IndexError`` (an
out-of-range index) or ``RuntimeError`` (a structural inconsistency), and returns
``None`` for a consistent grid.

You normally do not need to call it yourself:

* it runs automatically when a grid is loaded from a pickle or the binary format
(via ``set_state``);
* every grid loader (``init_from_pandapower``, ``init_from_pypowsybl``,
``init_from_matpower``, ``init_from_powermodels``) calls it before returning.

It is exposed so you can validate a grid you built or modified by hand. It runs
in time proportional to the number of elements in the grid, so it is cheap

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it is relatively cheap

compared to a powerflow.

.. note::

Release wheels are compiled with ``-O3 -DNDEBUG``, which removes Eigen's and
the standard library's own bounds checks. ``check_grid()`` (and the size /
finiteness check applied to the voltages a solver returns) are deliberately
*not* gated behind that flag: they always run, because they are what turns a
malformed input into a clean exception rather than an out-of-bounds access.

A note for solver-plugin authors
---------------------------------

A solver plugin is trusted code, so lightsim2grid does not try to sandbox it.
It does, however, sanity-check the voltages your ``compute_pf`` returns before
using them: if you report convergence but return a voltage vector of the wrong
size, the powerflow raises ``RuntimeError`` (this would otherwise be an
out-of-bounds read); if you return non-finite values, the solve is reported as
non-converged rather than propagating ``NaN`` / ``Inf`` into the results. Writing
a correct plugin therefore means returning ``V`` / ``Va`` / ``Vm`` sized to the
solver's bus count, and reporting non-convergence yourself when appropriate.
4 changes: 4 additions & 0 deletions lightsim2grid/network/from_pandapower/initLSGrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,8 @@ def init(pp_net: "pandapower.auxiliary.pandapowerNet",
# deal with slack bus
_aux_add_slack(model, pp_net, pp_to_ls, pp_orig_file)

# make sure the grid we just built is internally consistent (bus / substation
# / topology-vector indices in range, no NaN/Inf in the physical inputs)
model.check_grid()

return model
6 changes: 6 additions & 0 deletions lightsim2grid/network/from_powermodels/initLSGrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,10 @@ def init(network: dict,
# init the HVDC lines, if any
_aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus)

# make sure the grid we just built is internally consistent (bus / substation
# / topology-vector indices in range, no NaN/Inf in the physical inputs).
# This also covers grids loaded from matpower, which are converted to the
# powermodels format and routed through this function.
model.check_grid()

return model
5 changes: 5 additions & 0 deletions lightsim2grid/network/from_pypowsybl/initLSGrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ def init(net : pypo.network.Network,
model.set_line_to_sub2_id(lex_sub["sub_id"].values)
model.set_trafo_to_sub1_id(tor_sub["sub_id"].values)
model.set_trafo_to_sub2_id(tex_sub["sub_id"].values)

# make sure the grid we just built is internally consistent (bus / substation
# / topology-vector indices in range, no NaN/Inf in the physical inputs)
model.check_grid()

if not return_sub_id:
return model
else:
Expand Down
12 changes: 9 additions & 3 deletions lightsim2grid/tests/test_binary_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,17 @@ def _check_load(self, corrupted, description):
f.write(corrupted)
try:
self.grid_cls.load_binary(bad_path)
except RuntimeError:
pass # corruption detected and rejected cleanly: fine
except (RuntimeError, IndexError):
# Corruption detected and rejected cleanly. RuntimeError comes from the
# byte layer (BinaryArchive: bad magic/version/type, truncation,
# corrupted length). IndexError comes from the semantic layer
# (check_grid, run on load): a file that is byte-wise well-formed but
# carries an out-of-range index (bus / substation / topology-vector).
# Either way it is a clean, catchable exception -- never a crash.
pass
except BaseException as exc:
self.fail(f"{description}: load_binary raised {type(exc).__name__} "
f"instead of RuntimeError: {exc}")
f"instead of RuntimeError / IndexError: {exc}")

def test_single_byte_flips(self):
"""Invert one byte at every offset of the file."""
Expand Down
85 changes: 85 additions & 0 deletions lightsim2grid/tests/test_check_grid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright (c) 2020-2026, RTE (https://www.rte-france.com)
# See AUTHORS.txt
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform.

"""
Tests for ``LSGrid.check_grid()`` (the whole-grid consistency validator) from
Python.

``check_grid()`` range-checks every index the grid carries (bus / substation /
topology-vector) and the finiteness of the physical inputs; it is called
automatically when a grid is loaded (pickle / binary format, via ``set_state``)
and by every grid loader (pandapower / pypowsybl / matpower / powermodels). The
deep ``set_state`` poison cases are covered by the C++ suite (test_check_grid.cpp,
also run under ASan/UBSan/valgrind); here we check the Python-visible behaviour:

* a real grid loaded from pandapower passes (the loader calls check_grid, so a
false positive would make the loader itself raise);
* check_grid() is reachable from Python and rejects an inconsistent
topology-vector with a clean exception (``IndexError`` for an out-of-range
index -- C++ ``std::out_of_range`` --, ``RuntimeError`` for a duplicate --
C++ ``std::runtime_error``), never a crash.
"""

import unittest
import warnings
import numpy as np

import pandapower.networks as pn
from lightsim2grid.network import init_from_pandapower


# an id far above any element count, but still inside the C `int` range
BIG = 10 ** 6


class TestCheckGrid(unittest.TestCase):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you do same type of tests for substation id (***_to_subid)

def setUp(self):
with warnings.catch_warnings():
warnings.filterwarnings("ignore")
net = pn.case14()
self.grid = init_from_pandapower(net) # the loader calls check_grid()
self.n_load = len(self.grid.get_loads())
self.assertGreater(self.n_load, 1) # case14 has several loads

def test_loader_accepts_valid_grid(self):
# init_from_pandapower already ran check_grid() in setUp without raising;
# calling it again explicitly must also succeed and return None.
self.assertIsNone(self.grid.check_grid())

def test_duplicated_pos_topo_vect_is_rejected(self):
# put every load at the same position in the topology vector: the collected
# pos_topo_vect entries can then no longer be a permutation of [0, dim_topo).
self.grid.set_load_pos_topo_vect(np.zeros(self.n_load, dtype=np.int32))
with self.assertRaises(RuntimeError):
self.grid.check_grid()

def test_out_of_range_pos_topo_vect_is_rejected(self):
# distinct positions, but one is far out of range.
pos = np.arange(self.n_load, dtype=np.int32)
pos[-1] = BIG
self.grid.set_load_pos_topo_vect(pos)
with self.assertRaises(IndexError):
self.grid.check_grid()

def test_negative_pos_topo_vect_is_rejected(self):
pos = np.arange(self.n_load, dtype=np.int32)
pos[0] = -1
self.grid.set_load_pos_topo_vect(pos)
with self.assertRaises(IndexError):
self.grid.check_grid()

def test_pickle_roundtrip_of_valid_grid(self):
# pickling goes through set_state on load, which runs check_grid(); a valid
# grid must round-trip cleanly.
import pickle
restored = pickle.loads(pickle.dumps(self.grid))
self.assertIsNone(restored.check_grid())


if __name__ == "__main__":
unittest.main()
11 changes: 9 additions & 2 deletions src/bindings/python/binary_helpers.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ namespace py = pybind11;
// Files are readable by any lightsim2grid version sharing the same
// BINARY_FORMAT_VERSION (see BinaryArchive.hpp), and ill-formed input
// (garbage, truncation, corrupted sizes, wrong object type, trailing bytes)
// raises RuntimeError instead of crashing or over-allocating.
// raises RuntimeError instead of crashing or over-allocating. Loading a whole
// grid additionally runs LSGrid::check_grid() (via set_state), so a byte-wise
// well-formed but inconsistent grid -- an out-of-range bus / substation /
// topology-vector index -- is rejected with an IndexError (out-of-range index)
// or a RuntimeError (structural inconsistency).
//
// These lambdas call ls2g::save_binary_generic/load_binary_generic directly
// (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are
Expand All @@ -46,7 +50,10 @@ void add_binary_serialization(py::class_<T>& cls) {
"Load an object previously saved with save_binary(). Raises RuntimeError on "
"an incompatible binary format, a wrong object type, or a corrupted / "
"truncated file (including corrupted internal sizes: no attempt is made to "
"allocate more data than the file actually contains).");
"allocate more data than the file actually contains). Loading a whole grid "
"additionally validates its consistency (see check_grid): a byte-wise "
"well-formed but inconsistent grid raises IndexError (out-of-range index) "
"or RuntimeError (structural inconsistency).");
}

#endif // BINARY_HELPERS_HPP
3 changes: 3 additions & 0 deletions src/bindings/python/binding_lsgrid.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ views (eg `LightsimResultNetwork`), never by any C++ powerflow logic.
add_pickle(lsgrid_cls, "LSGrid");
add_binary_serialization(lsgrid_cls);
lsgrid_cls
// whole-grid consistency validation
.def("check_grid", &LSGrid::check_grid, DocLSGrid::check_grid.c_str())

// algo config (scaling/refactor policy params)
.def("get_ac_algo_config", &LSGrid::get_ac_algo_config,
"Return the AC solver's AlgoConfig (scaling/refactor policy type and parameters).")
Expand Down
4 changes: 4 additions & 0 deletions src/core/AlgorithmSelector.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@ class LS2G_API AlgorithmSelector final
return get_prt_solver("get_error", true)->get_error();
}

void set_error(ErrorType error) {
get_prt_solver("set_error", true)->set_error(error);
}

int get_nb_iter() const {
return get_prt_solver("get_nb_iter", true)->get_nb_iter();
}
Expand Down
Loading
Loading