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
Channel |
Trust required |
|---|---|
Unpickling a grid ( |
Trusted input only. Loading a pickle can execute arbitrary Python
code by design — this is a property of Python’s |
Loading a solver 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. |
Creating a grid2op environment ( |
Trusted input only. An environment is not just data: grid2op reads
and executes python code shipped with it (its |
Loading a grid from the fast binary format ( |
Least dangerous, still not a safe channel. See below. |
Building a grid from a source file (pandapower, pypowsybl, matpower, powermodels — including through a grid2op environment) |
Validated on load. See below. |
Pickle, plugins and grid2op environments are trusted-input-only by design
Unpickling arbitrary data, loading a native plugin or using a grid2op
environment are all, fundamentally, 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, nor call
grid2op.make, on something coming from a source you do not trust. This
is the same 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 least dangerous channel
Warning
“Least dangerous” is not “safe”: loading data you do not trust is a bad idea whatever the format, and none of the validation described here makes it a good one. The point below is only that this format gives an attacker strictly less to work with than pickle does — not that it is meant for untrusted input.
The fast binary format (Fast binary serialization (save_binary / load_binary)) was designed as an 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
lightsim2grid.network.LSGrid.check_grid() verifies that a grid
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 and
SVC slack / remote-regulated bus references. The topology-vector positions are
additionally required to form a permutation of [0, dim_topo) over exactly the
elements lightsim2grid.network.LSGrid.update_topo() drives (loads, generators, storage units,
powerlines and transformers): that is what makes them safe to index the
dim_topo-sized arrays that method is given, so a shunt, static generator, SVC
or hvdc line claiming a position in that vector is rejected. It checks the two
grid-wide per-unit scalars, sn_mva (the base power the whole grid is
normalised against) and init_vm_pu, are finite and strictly positive — a
degenerate value there does not make a powerflow fail, it makes it silently
wrong. It also checks the shape of the grid
itself: that the substation container’s own arrays agree with each other (the bus
count, the per-bus status vector and n_sub × nmax_busbar_per_sub all describe
the same set of buses), and that the bus-id mapping vectors carried alongside the
grid (_ls_to_orig / _orig_to_ls / _bus_fusion_rep) are in range. That
part matters as much as the per-element checks: the bus count is the bound the
element checks are expressed against, so validating elements against a grid whose
own arrays disagree would prove nothing. 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 relatively cheap compared to a powerflow.
What it deliberately does not check is the value of the per-element
electrical parameters: a line’s r / x, a transformer’s tap ratio, an
injection’s p / q. Values that look degenerate are usually legitimate —
r = 0 is a lossless branch, a negative reactance is a series capacitor,
and negative r / x also come out of three-winding-transformer star
equivalents (pandapower’s own case300 and case9241pegase carry them).
Non-finite values are legitimate too: pypowsybl encodes “no thermal limit” as a
non-finite limit_a_ka, and unbounded generator reactive limits are
conventionally ±Inf. None of these can corrupt anything either — they are
never used as indices, so they cannot cause an out-of-bounds access; they flow
into Ybus / Bbus and the solvers’ own guards report the solve as
non-converged.
What is checked are the two grid-wide scalars sn_mva and init_vm_pu.
They escape the safety net above: sn_mva scales results back to MW / MVar
outside the solver, so a bad value is invisible to the solver’s guards — a NaN
there produced a DC powerflow that reported convergence and returned NaN flows.
Warning
The solver’s guards are not a complete safety net for the derived results.
They check the bus voltages, not the branch flows computed from them, so a
grid whose model is degenerate in a way that leaves the voltages finite can
still produce a converged solve with non-finite flows. A connected branch
with r == 0 and x == 0 is the known case: its admittance
1 / (r + j.x) is infinite, and in DC the flows come out non-finite while
the solve reports success. Such a branch is not rejected on load, because a
lossless branch can be perfectly legitimate (an ideal step-up transformer),
and init_from_pypowsybl(fuse_zero_impedance_branches=True) exists exactly
to fuse the buses of the ones that are not. Check that the flows you get
back are finite if your grid may contain zero-impedance branches.
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 an external 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.
The memory-safety invariant: raise, never corrupt
Underneath all of the above is a single design rule the C++ core holds itself
to: a LSGrid must never corrupt process
memory. Whatever it is handed — a malformed pickle or binary file, a grid
loaded from a source file, or a perfectly ordinary Python API call made in the
wrong order or with an out-of-range id — the worst it is allowed to do is raise
a clean Python exception (IndexError / RuntimeError / ValueError /
…). It must not read or write out of bounds, use freed memory, or leave the
grid in a state that makes the next call do so. C++ is not a memory-safe
language and release wheels are built -O3 -DNDEBUG (so neither Eigen’s nor
the standard library’s own bounds checks are compiled in), so this is enforced
by explicit validation on every entry point rather than by the language: an id
or size that reaches the core from outside is range-checked before it is used
to index anything, and a grid restored from a serialized state is put through
check_grid() before it is usable.
This invariant is about safety, not correctness of the answer: a grid you
built with nonsensical (but in-range and consistent) parameters can still return
a wrong or non-converged powerflow — see the check_grid() notes above on the
values it deliberately does not police. It is also independent of the trust
boundaries above: those tell you which inputs are safe to accept; this tells
you that whatever you do accept, a bug in it surfaces as an exception you can
catch, not as silent heap corruption. If you ever observe a genuine crash
(segfault, malloc/free abort, ASan/UBSan report) instead of an
exception, that is a bug in lightsim2grid — please report it.