Skip to content

Commit fa2ea5c

Browse files
adding some invariants
1 parent 87dbfdd commit fa2ea5c

13 files changed

Lines changed: 420 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
```
2020

2121
This gives UI, plugin, and monitoring consumers a lightweight MQTT-native way to observe assignment changes while the leader database remains authoritative.
22+
- Bumps the base RPi OS image to 18 Jun 2026 which includes Linux kernel 6.18 (_new_ images only).
2223

2324
#### Bug fixes
2425

core/pioreactor/calibrations/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pioreactor import types as pt
1313
from pioreactor.calibrations.registry import CalibrationProtocol # re-export
1414
from pioreactor.calibrations.registry import get_calibration_protocols # re-export
15+
from pioreactor.structs import artifact_path_component
1516
from pioreactor.utils import local_persistent_storage
1617
from pioreactor.whoami import is_testing_env
1718

@@ -62,11 +63,13 @@ def load_active_calibration(device: Device) -> structs.AnyCalibration | None:
6263

6364

6465
def load_calibration(device: Device, calibration_name: str) -> structs.AnyCalibration:
65-
target_file = CALIBRATION_PATH / device / f"{calibration_name}.yaml"
66+
valid_device = artifact_path_component(device, "device")
67+
valid_calibration_name = artifact_path_component(calibration_name, "calibration_name")
68+
target_file = CALIBRATION_PATH / valid_device / f"{valid_calibration_name}.yaml"
6669

6770
if not target_file.is_file():
6871
raise FileNotFoundError(
69-
f"Calibration {calibration_name} was not found in {CALIBRATION_PATH / device}"
72+
f"Calibration {calibration_name} was not found in {CALIBRATION_PATH / valid_device}"
7073
)
7174
elif target_file.stat().st_size == 0:
7275
raise FileNotFoundError(f"Calibration {calibration_name} is empty")
@@ -79,7 +82,8 @@ def load_calibration(device: Device, calibration_name: str) -> structs.AnyCalibr
7982

8083

8184
def list_of_calibrations_by_device(device: Device) -> list[str]:
82-
device_dir = CALIBRATION_PATH / device
85+
valid_device = artifact_path_component(device, "device")
86+
device_dir = CALIBRATION_PATH / valid_device
8387
if not device_dir.is_dir():
8488
return []
8589
return [file.stem for file in device_dir.glob("*.yaml")]

core/pioreactor/estimators/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from msgspec.yaml import decode as yaml_decode
1212
from pioreactor import structs
1313
from pioreactor import types as pt
14+
from pioreactor.structs import artifact_path_component
1415
from pioreactor.utils import local_persistent_storage
1516
from pioreactor.whoami import is_testing_env
1617

@@ -21,6 +22,8 @@
2122

2223

2324
def _estimator_path_for(device: str, name: str) -> Path:
25+
device = artifact_path_component(device, "device")
26+
name = artifact_path_component(name, "estimator_name")
2427
return ESTIMATOR_PATH / device / f"{name}.yaml"
2528

2629

@@ -67,7 +70,8 @@ def load_estimator(device: Device, estimator_name: str) -> structs.AnyEstimator:
6770

6871

6972
def list_of_estimators_by_device(device: Device) -> list[str]:
70-
device_dir = ESTIMATOR_PATH / device
73+
valid_device = artifact_path_component(device, "device")
74+
device_dir = ESTIMATOR_PATH / valid_device
7175
if not device_dir.is_dir():
7276
return []
7377
return [file.stem for file in device_dir.glob("*.yaml")]

core/pioreactor/structs.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pioreactor import exc
1717
from pioreactor import types as pt
1818
from pioreactor.logging import create_logger
19+
from pioreactor.utils.files import is_valid_unix_filename
1920

2021

2122
T = t.TypeVar("T")
@@ -73,6 +74,18 @@ def type(self) -> str:
7374
type CalibrationCurveData = PolyFitCoefficients | SplineFitData | AkimaFitData
7475

7576

77+
def artifact_path_component(value: str, field: str) -> str:
78+
"""
79+
Calibration and estimator identities are filesystem path components.
80+
81+
Keep this invariant at the artifact layer so CLI, API, and Huey writers
82+
cannot construct paths from malformed names.
83+
"""
84+
if not is_valid_unix_filename(value):
85+
raise ValueError(f"{field} must be a valid filename component.")
86+
return value
87+
88+
7689
class AutomationSettings(JSONPrintedStruct):
7790
"""
7891
Metadata produced when settings in an automation job change
@@ -226,8 +239,10 @@ def calibration_type(self) -> str:
226239
def path_on_disk_for_device(self, device: str) -> Path:
227240
from pioreactor.calibrations import CALIBRATION_PATH
228241

229-
calibration_dir = CALIBRATION_PATH / device
230-
out_file = calibration_dir / f"{self.calibration_name}.yaml"
242+
calibration_dir = CALIBRATION_PATH / artifact_path_component(device, "device")
243+
out_file = (
244+
calibration_dir / f"{artifact_path_component(self.calibration_name, 'calibration_name')}.yaml"
245+
)
231246
return out_file
232247

233248
def save_to_disk_for_device(self, device: str) -> str:
@@ -248,29 +263,37 @@ def set_as_active_calibration_for_device(self, device: str) -> None:
248263
from pioreactor.utils import local_persistent_storage
249264

250265
logger = create_logger("calibrations", experiment="$experiment")
266+
device = artifact_path_component(device, "device")
267+
calibration_name = artifact_path_component(self.calibration_name, "calibration_name")
251268

252269
if not self.exists_on_disk_for_device(device):
253270
self.save_to_disk_for_device(device)
254271

255272
with local_persistent_storage("active_calibrations") as c:
256-
c[device] = self.calibration_name
273+
c[device] = calibration_name
257274

258275
logger.info(f"Set {self.calibration_name} as active calibration for {device}")
259276

260277
def remove_as_active_calibration_for_device(self, device: str) -> None:
261278
from pioreactor.utils import local_persistent_storage
262279

263280
logger = create_logger("calibrations", experiment="$experiment")
281+
device = artifact_path_component(device, "device")
282+
calibration_name = artifact_path_component(self.calibration_name, "calibration_name")
264283

265284
with local_persistent_storage("active_calibrations") as c:
266-
if c.get(device) == self.calibration_name:
285+
if c.get(device) == calibration_name:
267286
del c[device]
268287
logger.info(f"Removed {self.calibration_name} as active calibration for {device}")
269288

270289
def exists_on_disk_for_device(self, device: str) -> bool:
271290
from pioreactor.calibrations import CALIBRATION_PATH
272291

273-
target_file = CALIBRATION_PATH / device / f"{self.calibration_name}.yaml"
292+
target_file = (
293+
CALIBRATION_PATH
294+
/ artifact_path_component(device, "device")
295+
/ f"{artifact_path_component(self.calibration_name, 'calibration_name')}.yaml"
296+
)
274297

275298
return target_file.is_file()
276299

@@ -377,7 +400,9 @@ def is_active(self, device: str) -> bool:
377400
from pioreactor.utils import local_persistent_storage
378401

379402
with local_persistent_storage("active_calibrations") as c:
380-
return c.get(device) == self.calibration_name
403+
return c.get(artifact_path_component(device, "device")) == artifact_path_component(
404+
self.calibration_name, "calibration_name"
405+
)
381406

382407

383408
class EstimatorBase(Struct, tag_field="estimator_type", kw_only=True):
@@ -392,8 +417,8 @@ def estimator_type(self) -> str:
392417
def path_on_disk_for_device(self, device: str) -> Path:
393418
from pioreactor.estimators import ESTIMATOR_PATH
394419

395-
estimator_dir = ESTIMATOR_PATH / device
396-
out_file = estimator_dir / f"{self.estimator_name}.yaml"
420+
estimator_dir = ESTIMATOR_PATH / artifact_path_component(device, "device")
421+
out_file = estimator_dir / f"{artifact_path_component(self.estimator_name, 'estimator_name')}.yaml"
397422
return out_file
398423

399424
def save_to_disk_for_device(self, device: str) -> str:
@@ -414,37 +439,47 @@ def set_as_active_calibration_for_device(self, device: str) -> None:
414439
from pioreactor.utils import local_persistent_storage
415440

416441
logger = create_logger("estimators", experiment="$experiment")
442+
device = artifact_path_component(device, "device")
443+
estimator_name = artifact_path_component(self.estimator_name, "estimator_name")
417444

418445
if not self.exists_on_disk_for_device(device):
419446
self.save_to_disk_for_device(device)
420447

421448
with local_persistent_storage("active_estimators") as c:
422-
c[device] = self.estimator_name
449+
c[device] = estimator_name
423450

424451
logger.info(f"Set {self.estimator_name} as active estimator for {device}")
425452

426453
def remove_as_active_calibration_for_device(self, device: str) -> None:
427454
from pioreactor.utils import local_persistent_storage
428455

429456
logger = create_logger("estimators", experiment="$experiment")
457+
device = artifact_path_component(device, "device")
458+
estimator_name = artifact_path_component(self.estimator_name, "estimator_name")
430459

431460
with local_persistent_storage("active_estimators") as c:
432-
if c.get(device) == self.estimator_name:
461+
if c.get(device) == estimator_name:
433462
del c[device]
434463
logger.info(f"Removed {self.estimator_name} as active estimator for {device}")
435464

436465
def exists_on_disk_for_device(self, device: str) -> bool:
437466
from pioreactor.estimators import ESTIMATOR_PATH
438467

439-
target_file = ESTIMATOR_PATH / device / f"{self.estimator_name}.yaml"
468+
target_file = (
469+
ESTIMATOR_PATH
470+
/ artifact_path_component(device, "device")
471+
/ f"{artifact_path_component(self.estimator_name, 'estimator_name')}.yaml"
472+
)
440473

441474
return target_file.is_file()
442475

443476
def is_active(self, device: str) -> bool:
444477
from pioreactor.utils import local_persistent_storage
445478

446479
with local_persistent_storage("active_estimators") as c:
447-
return c.get(device) == self.estimator_name
480+
return c.get(artifact_path_component(device, "device")) == artifact_path_component(
481+
self.estimator_name, "estimator_name"
482+
)
448483

449484

450485
class ODCalibration(CalibrationBase, kw_only=True, tag="od"):

core/pioreactor/utils/files.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# -*- coding: utf-8 -*-
2+
import re
3+
4+
5+
_ALLOWED_UNIX_FILENAME = re.compile(r"^[A-Za-z0-9._-]+( [A-Za-z0-9._-]+)*$")
6+
7+
8+
def is_valid_unix_filename(name: str, *, max_bytes: int = 255) -> bool:
9+
"""
10+
Return True iff *name* is a single portable filename component.
11+
12+
Artifact names are path components, not arbitrary strings. Validate them
13+
before constructing calibration, estimator, profile, or descriptor paths.
14+
"""
15+
if not name:
16+
return False
17+
if name in {".", ".."}:
18+
return False
19+
if name[0] in ".-":
20+
return False
21+
if "/" in name or "\\" in name:
22+
return False
23+
if any(ord(c) < 0x20 for c in name):
24+
return False
25+
if len(name.encode()) > max_bytes:
26+
return False
27+
return bool(_ALLOWED_UNIX_FILENAME.fullmatch(name))

core/pioreactor/web/tasks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,12 @@ def pio_update_app(*args: str, env: dict[str, str] | None = None) -> bool:
14371437

14381438
@huey.task()
14391439
def rm(path: str) -> bool:
1440+
"""
1441+
Delete a validated path.
1442+
1443+
This is a low-level sink. Callers must first constrain the path to the
1444+
intended root; this task deliberately does not infer containment.
1445+
"""
14401446
logger.debug(f"Deleting {path}.")
14411447
if whoami.is_testing_env():
14421448
return True
@@ -1543,6 +1549,12 @@ def pios(*args: str, env: dict[str, str] | None = None) -> bool:
15431549

15441550
@huey.task()
15451551
def save_file(path: str, content: str) -> bool:
1552+
"""
1553+
Write to a validated path.
1554+
1555+
This is a low-level sink. Callers must first constrain the path to the
1556+
intended root and validate filename components where user input is involved.
1557+
"""
15461558
try:
15471559
with open(path, "w") as f:
15481560
f.write(content)

0 commit comments

Comments
 (0)