Skip to content
Open
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
18 changes: 18 additions & 0 deletions tests/pytest/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Suite-wide test setup.

Point the runtime's persistent and ephemeral directories at a temp location
BEFORE any test imports ``webserver.config`` (directly, or transitively via
``webserver.vpp_license_debug`` / ``webserver.plcapp_management``). ``config``
creates ``PERSISTENT_DATA_DIR`` / ``RUNTIME_DIR`` at import time; without this
override the import would try to create ``/var/lib/openplc-runtime`` on a CI box
and the whole module would be skipped instead of tested.

``setdefault`` so a more specific conftest (e.g. restapi) can still choose its
own paths.
"""
import os
import tempfile

_TMP = os.path.join(tempfile.gettempdir(), "openplc-runtime-tests")
os.environ.setdefault("OPENPLC_RUNTIME_DIR", os.path.join(_TMP, "run"))
os.environ.setdefault("OPENPLC_PERSISTENT_DATA_DIR", os.path.join(_TMP, "data"))
53 changes: 53 additions & 0 deletions tests/pytest/plugins/test_vpp_license_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,56 @@ def test_resolve_license_path_returns_none_for_a_prefix_sibling(tmp_path):

assert lic.resolve_license_path(escaping, str(root)) is None
assert lic.resolve_license_path(str(root / "plugin.json"), str(root)) == str(root / "plugin.license")


def test_resolve_license_path_accepts_the_persistent_dir(tmp_path, monkeypatch):
"""The widened guard accepts config.VPP_DATA_DIR as a SECOND root -- the
location apply_vpp_plugin_conf relocates configs to so a license survives a
runtime update -- while still refusing anything outside both roots."""
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(lic.config, "VPP_DATA_DIR", persist)
root = tmp_path / "runtime"
root.mkdir()

# A config_path under the persistent dir resolves, even though it is NOT
# under the runtime root passed in.
assert lic.resolve_license_path(str(persist / "rpi.json"), str(root)) == str(persist / "rpi.license")
# Still fails closed for a path outside BOTH roots.
assert lic.resolve_license_path(str(tmp_path / "elsewhere" / "x.json"), str(root)) is None


def test_write_then_read_roundtrip_in_persistent_dir(tmp_path, monkeypatch):
"""0x49/0x4A round-trip when config_path points at the persistent dir (as it
does after apply relocates it). The .license lands OUTSIDE the runtime cwd,
which is exactly what lets it survive a build/ wipe on the next update."""
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(lic.config, "VPP_DATA_DIR", persist)

cwd = tmp_path / "runtime"
cwd.mkdir()
monkeypatch.chdir(cwd) # cwd != persist on purpose
(cwd / "vpp_plugins.conf").write_text("dummy\n")
config_path = str(persist / "rpi_gpio.json")

class _P:
name = "rpi_gpio"

def __init__(self, cp):
self.config_path = cp

class _Conf:
plugins = [_P(config_path)]

monkeypatch.setattr(lic.PluginsConfiguration, "from_file", classmethod(lambda cls, _p: _Conf()))

blob = _golden_blob()
assert lic.handle_license_command(_hex(bytes([0x49, 0x00, 0x62]) + blob)) == "49 7E"
# Landed in the persistent dir, not under cwd/build/vpp.
assert os.path.exists(persist / "rpi_gpio.license")
assert not os.path.exists(cwd / "build" / "vpp" / "rpi_gpio.license")

read = lic.handle_license_command("4A")
assert read.startswith("4A 7E 00 62")
assert bytes(int(p, 16) for p in read.split()[4:]) == blob
153 changes: 124 additions & 29 deletions tests/pytest/plugins/test_vpp_license_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,53 +64,148 @@ def test_delivery_path_matches_plugin_derivation(config_path):
assert _runtime_license_dest(config_path) == _plugin_license_path(config_path)


def test_apply_vpp_plugin_conf_delivers_license(tmp_path, monkeypatch):
"""Integration: a conf/<plugin>.license in the upload is copied to the sibling
of the plugin's config_path; absence leaves no license (device -> demo)."""
def test_apply_vpp_plugin_conf_relocates_to_persistent_dir(tmp_path, monkeypatch):
"""Integration: apply relocates config+license into PERSISTENT_DATA_DIR/vpp
(NOT build/vpp) and rewrites config_path in vpp_plugins.conf to that absolute
path, so a runtime update -- which wipes build/ -- cannot delete the license.
The .so path stays under build/vpp (it is code, rebuilt each upload)."""
mgmt = pytest.importorskip(
"webserver.plcapp_management",
reason="runtime webserver package not importable (no venv)",
)

# Fake a single native plugin whose config_path lives under the temp cwd.
# Persistent dir lives OUTSIDE the runtime cwd on purpose -- that is the whole
# point of the change. Point the module's VPP_DATA_DIR at a temp location so
# the test never touches /var/lib.
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(mgmt, "VPP_DATA_DIR", persist)

cwd = tmp_path / "runtime"
(cwd).mkdir()
cwd.mkdir()
monkeypatch.chdir(cwd)
config_path = str(cwd / "build" / "vpp" / "rpi_gpio.json")
monkeypatch.setattr(mgmt.build_state, "log", lambda *_a, **_k: None, raising=False)

# `path` is not decoration: apply_vpp_plugin_conf now runs the uploaded conf
# through validate_vpp_plugins_conf first, which requires every VPP plugin's
# .so to resolve inside build/vpp/. A fake without it would only prove the
# fake is out of date.
class _P:
name = "rpi_gpio"
# A REAL uploaded conf (no from_file monkeypatch): the .so path is relative
# and inside build/vpp so validate_vpp_plugins_conf accepts it; config_path is
# what the editor emits (relative build/vpp) and what apply must rewrite.
gen = tmp_path / "generated"
(gen / "conf").mkdir(parents=True)
(gen / "vpp_plugins.conf").write_text(
"rpi_gpio,./build/vpp/librpi_gpio_plugin.so,1,1,build/vpp/rpi_gpio.json,\n"
)
(gen / "conf" / "rpi_gpio.json").write_text("{}\n")
(gen / "conf" / "rpi_gpio.license").write_bytes(b"\x4f\x50\x4c\x43" + b"\x00" * 94) # 98-byte blob

def __init__(self, cp, so):
self.config_path = cp
self.path = so
mgmt.apply_vpp_plugin_conf(str(gen))

# Config and license landed in the persistent dir, not build/vpp.
assert os.path.exists(persist / "rpi_gpio.json")
assert os.path.exists(persist / "rpi_gpio.license")
assert os.path.getsize(persist / "rpi_gpio.license") == 98
assert not os.path.exists(cwd / "build" / "vpp" / "rpi_gpio.license")

# vpp_plugins.conf was rewritten: config_path -> persistent absolute; the .so
# path is untouched (stays under build/vpp).
rewritten = mgmt.PluginsConfiguration.from_file(str(cwd / "vpp_plugins.conf"))
plugin = rewritten.plugins[0]
assert plugin.config_path == str(persist / "rpi_gpio.json")
assert plugin.path == "./build/vpp/librpi_gpio_plugin.so"

class _Conf:
plugins = [_P(config_path, "./build/vpp/librpi_gpio_plugin.so")]

monkeypatch.setattr(mgmt.PluginsConfiguration, "from_file", classmethod(lambda cls, _p: _Conf()))
def test_persistent_license_survives_an_upload_without_a_license(tmp_path, monkeypatch):
"""A re-upload that does not carry a .license must NOT wipe the license the
device already holds in the persistent dir -- that survival is the point."""
mgmt = pytest.importorskip(
"webserver.plcapp_management",
reason="runtime webserver package not importable (no venv)",
)
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(mgmt, "VPP_DATA_DIR", persist)
cwd = tmp_path / "runtime"
cwd.mkdir()
monkeypatch.chdir(cwd)
monkeypatch.setattr(mgmt.build_state, "log", lambda *_a, **_k: None, raising=False)

# Build the uploaded generated_dir: vpp_plugins.conf + conf/{json,license}.
gen = tmp_path / "generated"
(gen / "conf").mkdir(parents=True)
(gen / "vpp_plugins.conf").write_text("dummy\n")
conf_line = "rpi_gpio,./build/vpp/librpi_gpio_plugin.so,1,1,build/vpp/rpi_gpio.json,\n"
(gen / "vpp_plugins.conf").write_text(conf_line)
(gen / "conf" / "rpi_gpio.json").write_text("{}\n")
(gen / "conf" / "rpi_gpio.license").write_bytes(b"\x4f\x50\x4c\x43" + b"\x00" * 94) # 98-byte blob
(gen / "conf" / "rpi_gpio.license").write_bytes(b"\x4f\x50\x4c\x43" + b"\x00" * 94)

mgmt.apply_vpp_plugin_conf(str(gen))
assert os.path.exists(persist / "rpi_gpio.license")

expected = config_path[:-5] + ".license"
assert os.path.exists(expected), "license blob not delivered to the plugin's sibling path"
assert os.path.getsize(expected) == 98

# Second pass without a .license in the upload must not resurrect a stale one
# from the same source (delivery only copies what the upload carries).
os.remove(expected)
# Second upload of the same VPP, this time WITHOUT the license blob.
(gen / "conf" / "rpi_gpio.license").unlink()
mgmt.apply_vpp_plugin_conf(str(gen))
assert not os.path.exists(expected)

assert os.path.exists(persist / "rpi_gpio.license"), "persistent license must survive a license-less upload"
assert os.path.getsize(persist / "rpi_gpio.license") == 98


def test_migration_rescues_a_pre_change_license_from_build_vpp(tmp_path, monkeypatch):
"""A device licensed before this change has its blob at build/vpp/<name>.license.
When the update did not wipe build/ (the wipe is conditional on CMakeCache.txt),
the next upload without a bundled license migrates that blob into the persistent
dir instead of leaving it orphaned."""
mgmt = pytest.importorskip(
"webserver.plcapp_management",
reason="runtime webserver package not importable (no venv)",
)
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(mgmt, "VPP_DATA_DIR", persist)
cwd = tmp_path / "runtime"
(cwd / "build" / "vpp").mkdir(parents=True)
monkeypatch.chdir(cwd)
monkeypatch.setattr(mgmt.build_state, "log", lambda *_a, **_k: None, raising=False)

old = cwd / "build" / "vpp" / "rpi_gpio.license" # pre-change location
old.write_bytes(b"\x4f\x50\x4c\x43" + b"\x00" * 94)

gen = tmp_path / "generated"
(gen / "conf").mkdir(parents=True)
(gen / "vpp_plugins.conf").write_text(
"rpi_gpio,./build/vpp/librpi_gpio_plugin.so,1,1,build/vpp/rpi_gpio.json,\n"
)
(gen / "conf" / "rpi_gpio.json").write_text("{}\n") # no .license in the upload

mgmt.apply_vpp_plugin_conf(str(gen))
assert (persist / "rpi_gpio.license").read_bytes() == old.read_bytes()


def test_migration_ignores_a_license_a_forged_config_path_points_at(tmp_path, monkeypatch):
"""Security: the migration source is the FIXED build/vpp/<name>.license, never
config_path. validate_vpp_plugins_conf only confines config_path to the runtime
root, so a forged conf could name a .license elsewhere under the root; that file
must NOT be copied to where 0x4A would read it back."""
mgmt = pytest.importorskip(
"webserver.plcapp_management",
reason="runtime webserver package not importable (no venv)",
)
persist = tmp_path / "data" / "vpp"
persist.mkdir(parents=True)
monkeypatch.setattr(mgmt, "VPP_DATA_DIR", persist)
cwd = tmp_path / "runtime"
(cwd / "build" / "vpp").mkdir(parents=True)
monkeypatch.chdir(cwd)
monkeypatch.setattr(mgmt.build_state, "log", lambda *_a, **_k: None, raising=False)

# A decoy .license elsewhere in the runtime root (passes the validator's
# runtime-root confinement) that a forged config_path tries to point at.
(cwd / "secrets").mkdir()
(cwd / "secrets" / "target.license").write_bytes(b"\x4f\x50\x4c\x43" + b"\x00" * 94)

gen = tmp_path / "generated"
(gen / "conf").mkdir(parents=True)
(gen / "vpp_plugins.conf").write_text(
"rpi_gpio,./build/vpp/librpi_gpio_plugin.so,1,1,secrets/target.json,\n"
)
(gen / "conf" / "rpi_gpio.json").write_text("{}\n") # no .license in the upload

mgmt.apply_vpp_plugin_conf(str(gen))
# migration looked at build/vpp/rpi_gpio.license (absent), NOT secrets/target.license
assert not (persist / "rpi_gpio.license").exists()
13 changes: 13 additions & 0 deletions webserver/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ def get_persistent_data_dir():
PERSISTENT_DATA_DIR = get_persistent_data_dir()
ENV_PATH = PERSISTENT_DATA_DIR / ".env"
DB_PATH = PERSISTENT_DATA_DIR / "restapi.db"
# VPP plugin configs + license blobs live here, OUTSIDE $OPENPLC_DIR/build, so a
# runtime version update (install.sh does ``rm -rf $OPENPLC_DIR/build``) can never
# delete a purchased license. The closed .so still reads them because the runtime
# writes this absolute path into vpp_plugins.conf's config_path field (see
# webserver/plcapp_management.py::apply_vpp_plugin_conf); the C loader passes
# config_path to the plugin verbatim, so only the .so binary itself must stay
# under build/vpp.
#
# Created on demand by apply_vpp_plugin_conf / _write_license_atomically, NOT at
# import: a bare module-scope mkdir is import-time filesystem work that turns a
# permission failure into a hard import crash (the very thing tests/pytest/
# conftest.py exists to work around).
VPP_DATA_DIR = PERSISTENT_DATA_DIR / "vpp"
BASE_DIR = os.path.abspath(os.path.dirname(__file__))


Expand Down
Loading