Better error validation for printer profiles

Data types loaded from disk were not properly ensured to match expected types and input validation also had deficits.

Should fix #714
This commit is contained in:
Gina Häußge 2015-01-12 15:07:14 +01:00
parent f5eef06e88
commit f238ef40ec
5 changed files with 148 additions and 89 deletions

View file

@ -98,6 +98,7 @@
printer profiles printer profiles
* [#683](https://github.com/foosel/OctoPrint/issues/683) - Fixed heated bed option not being properly displayed in * [#683](https://github.com/foosel/OctoPrint/issues/683) - Fixed heated bed option not being properly displayed in
printer profiles printer profiles
* [#714](https://github.com/foosel/OctoPrint/issues/714) - Fixed type validation of printer profiles
* Various fixes without tickets: * Various fixes without tickets:
* GCODE viewer now doesn't stumble over completely extrusionless GCODE files * GCODE viewer now doesn't stumble over completely extrusionless GCODE files
* Do not deliver the API key on settings API unless user has admin rights * Do not deliver the API key on settings API unless user has admin rights

View file

@ -9,17 +9,28 @@ __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms
import os import os
import copy import copy
import re import re
import logging
from octoprint.settings import settings from octoprint.settings import settings
from octoprint.util import dict_merge, dict_clean from octoprint.util import dict_merge, dict_clean, dict_contains_keys
class SaveError(Exception): class SaveError(Exception):
pass pass
class CouldNotOverwriteError(SaveError):
pass
class InvalidProfileError(Exception):
pass
class BedTypes(object): class BedTypes(object):
RECTANGULAR = "rectangular" RECTANGULAR = "rectangular"
CIRCULAR = "circular" CIRCULAR = "circular"
@classmethod
def values(cls):
return [getattr(cls, name) for name in cls.__dict__ if not name.startswith("__")]
class PrinterProfileManager(object): class PrinterProfileManager(object):
default = dict( default = dict(
@ -51,8 +62,8 @@ class PrinterProfileManager(object):
def __init__(self): def __init__(self):
self._current = None self._current = None
self._folder = settings().getBaseFolder("printerProfiles") self._folder = settings().getBaseFolder("printerProfiles")
self._logger = logging.getLogger(__name__)
def select(self, identifier): def select(self, identifier):
if identifier is None or not self.exists(identifier): if identifier is None or not self.exists(identifier):
@ -69,11 +80,14 @@ class PrinterProfileManager(object):
return self._load_all() return self._load_all()
def get(self, identifier): def get(self, identifier):
if identifier == "_default": try:
return self._load_default() if identifier == "_default":
elif self.exists(identifier): return self._load_default()
return self._load_from_path(self._get_profile_path(identifier)) elif self.exists(identifier):
else: return self._load_from_path(self._get_profile_path(identifier))
else:
return None
except InvalidProfileError:
return None return None
def remove(self, identifier): def remove(self, identifier):
@ -87,7 +101,7 @@ class PrinterProfileManager(object):
elif "name" in profile: elif "name" in profile:
identifier = profile["name"] identifier = profile["name"]
else: else:
raise ValueError("profile must contain either id or name") raise InvalidProfileError("profile must contain either id or name")
identifier = self._sanitize(identifier) identifier = self._sanitize(identifier)
profile["id"] = identifier profile["id"] = identifier
@ -95,6 +109,9 @@ class PrinterProfileManager(object):
if identifier == "_default": if identifier == "_default":
default_profile = dict_merge(self._load_default(), profile) default_profile = dict_merge(self._load_default(), profile)
if not self._ensure_valid_profile(default_profile):
raise InvalidProfileError()
settings().set(["printerProfiles", "defaultProfile"], default_profile, defaults=dict(printerProfiles=dict(defaultProfile=self.__class__.default))) settings().set(["printerProfiles", "defaultProfile"], default_profile, defaults=dict(printerProfiles=dict(defaultProfile=self.__class__.default)))
settings().save() settings().save()
else: else:
@ -144,10 +161,13 @@ class PrinterProfileManager(object):
all_identifiers = self._load_all_identifiers() all_identifiers = self._load_all_identifiers()
results = dict() results = dict()
for identifier, path in all_identifiers.items(): for identifier, path in all_identifiers.items():
if identifier == "_default": try:
profile = self._load_default() if identifier == "_default":
else: profile = self._load_default()
profile = self._load_from_path(path) else:
profile = self._load_from_path(path)
except InvalidProfileError:
continue
if profile is None: if profile is None:
continue continue
@ -176,9 +196,17 @@ class PrinterProfileManager(object):
import yaml import yaml
with open(path) as f: with open(path) as f:
profile = yaml.safe_load(f) profile = yaml.safe_load(f)
profile = self._ensure_valid_profile(profile)
if not profile:
self._logger.warn("Invalid profile: %s" % path)
raise InvalidProfileError()
return profile return profile
def _save_to_path(self, path, profile, allow_overwrite=False): def _save_to_path(self, path, profile, allow_overwrite=False):
validated_profile = self._ensure_valid_profile(profile)
if not validated_profile:
raise InvalidProfileError()
if os.path.exists(path) and not allow_overwrite: if os.path.exists(path) and not allow_overwrite:
raise SaveError("Profile %s already exists and not allowed to overwrite" % profile["id"]) raise SaveError("Profile %s already exists and not allowed to overwrite" % profile["id"])
@ -197,8 +225,12 @@ class PrinterProfileManager(object):
return False return False
def _load_default(self): def _load_default(self):
default_profile = settings().get(["printerProfiles", "defaultProfile"]) default_overrides = settings().get(["printerProfiles", "defaultProfile"])
return dict_merge(copy.deepcopy(self.__class__.default), default_profile) profile = self._ensure_valid_profile(dict_merge(copy.deepcopy(self.__class__.default), default_overrides))
if not profile:
self._logger.warn("Invalid default profile after applying overrides")
raise InvalidProfileError()
return profile
def _get_profile_path(self, identifier): def _get_profile_path(self, identifier):
return os.path.join(self._folder, "%s.profile" % identifier) return os.path.join(self._folder, "%s.profile" % identifier)
@ -216,3 +248,60 @@ class PrinterProfileManager(object):
sanitized_name = sanitized_name.replace(" ", "_") sanitized_name = sanitized_name.replace(" ", "_")
return sanitized_name return sanitized_name
def _ensure_valid_profile(self, profile):
# ensure all keys are present
if not dict_contains_keys(self.default, profile):
return False
# conversion helper
def convert_value(profile, path, converter):
value = profile
for part in path[:-1]:
if not isinstance(value, dict) or not part in value:
raise RuntimeError("%s is not contained in profile" % ".".join(path))
value = value[part]
if not isinstance(value, dict) or not path[-1] in value:
raise RuntimeError("%s is not contained in profile" % ".".join(path))
value[path[-1]] = converter(value[path[-1]])
# convert ints
for path in (("extruder", "count"), ("axes", "x", "speed"), ("axes", "y", "speed"), ("axes", "z", "speed")):
try:
convert_value(profile, path, int)
except:
return False
# convert floats
for path in (("volume", "width"), ("volume", "depth"), ("volume", "height"), ("extruder", "nozzleDiameter")):
try:
convert_value(profile, path, float)
except:
return False
# convert booleans
for path in (("axes", "x", "inverted"), ("axes", "y", "inverted"), ("axes", "z", "inverted")):
try:
convert_value(profile, path, bool)
except:
return False
# validate form factor
if not profile["volume"]["formFactor"] in BedTypes.values():
return False
# validate offsets
offsets = []
for offset in profile["extruder"]["offsets"]:
if not len(offset) == 2:
return False
x_offset, y_offset = offset
try:
offsets.append((float(x_offset), float(y_offset)))
except:
return False
profile["extruder"]["offsets"] = offsets
return profile

View file

@ -15,6 +15,7 @@ from octoprint.server.util.flask import restricted_access
from octoprint.util import dict_merge from octoprint.util import dict_merge
from octoprint.server import printerProfileManager from octoprint.server import printerProfileManager
from octoprint.printer.profile import InvalidProfileError, CouldNotOverwriteError, SaveError
@api.route("/printerprofiles", methods=["GET"]) @api.route("/printerprofiles", methods=["GET"])
@ -42,18 +43,26 @@ def printerProfilesAdd():
del base_profile["id"] del base_profile["id"]
if "name" in base_profile: if "name" in base_profile:
del base_profile["name"] del base_profile["name"]
profile = dict_merge(base_profile, json_data["profile"])
if not _validate_profile(profile):
return None, None, make_response("Profile is invalid, missing obligatory values", 400)
return _overwrite_profile(profile) profile = dict_merge(base_profile, json_data["profile"])
try:
saved_profile = printerProfileManager.save(profile, allow_overwrite=False)
except InvalidProfileError:
return make_response("Profile is invalid", 400)
except CouldNotOverwriteError:
return make_response("Profile already exists and overwriting was not allowed", 400)
except Exception as e:
return make_response("Could not save profile: %s" % e.message, 500)
else:
return jsonify(dict(profile=_convert_profile(saved_profile)))
@api.route("/printerprofiles/<string:identifier>", methods=["GET"]) @api.route("/printerprofiles/<string:identifier>", methods=["GET"])
def printerProfilesGet(identifier): def printerProfilesGet(identifier):
profile = printerProfileManager.get(identifier) profile = printerProfileManager.get(identifier)
if profile is None: if profile is None:
make_response("Unknown profile: %s" % identifier, 404) return make_response("Unknown profile: %s" % identifier, 404)
return jsonify(_convert_profile(profile)) else:
return jsonify(_convert_profile(profile))
@api.route("/printerprofiles/<string:identifier>", methods=["DELETE"]) @api.route("/printerprofiles/<string:identifier>", methods=["DELETE"])
@restricted_access @restricted_access
@ -84,15 +93,17 @@ def printerProfilesUpdate(identifier):
del new_profile["default"] del new_profile["default"]
new_profile["id"] = identifier new_profile["id"] = identifier
if not _validate_profile(new_profile):
make_response("Combined profile is invalid, missing obligatory values", 400)
try: try:
saved_profile = printerProfileManager.save(new_profile, allow_overwrite=True, make_default=make_default) saved_profile = printerProfileManager.save(new_profile, allow_overwrite=True, make_default=make_default)
except InvalidProfileError:
return make_response("Profile is invalid", 400)
except CouldNotOverwriteError:
return make_response("Profile already exists and overwriting was not allowed", 400)
except Exception as e: except Exception as e:
make_response("Could not save profile: %s" % e.message) return make_response("Could not save profile: %s" % e.message, 500)
else:
return jsonify(dict(profile=_convert_profile(saved_profile))) return jsonify(dict(profile=_convert_profile(saved_profile)))
def _convert_profiles(profiles): def _convert_profiles(profiles):
result = dict() result = dict()
@ -109,59 +120,3 @@ def _convert_profile(profile):
converted["default"] = (profile["id"] == default) converted["default"] = (profile["id"] == default)
converted["current"] = (profile["id"] == current) converted["current"] = (profile["id"] == current)
return converted return converted
def _validate_profile(profile):
if not "name" in profile \
and "volume" in profile \
and "width" in profile["volume"] \
and "depth" in profile["volume"] \
and "height" in profile["volume"] \
and "formFactor" in profile["volume"] \
and "heatedBed" in profile \
and "extruder" in profile \
and "count" in profile["extruder"] \
and "offsets" in profile["extruder"] \
and len(profile["extruder"]["offsets"]) == profile["extruder"]["count"]:
return False
for dimension in ("width", "depth", "height"):
try:
profile["volume"][dimension] = float(profile["volume"][dimension])
except:
return False
if not profile["volume"]["formFactor"] in ("rectangular", "circular"):
return False
try:
profile["heatedBed"] = bool(profile["heatedBed"])
except:
return False
try:
profile["extruder"]["count"] = int(profile["extruder"]["count"])
except:
return False
converted_offsets = []
for offset in profile["extruder"]["offsets"]:
try:
converted_offsets.append((float(offset[0]), float(offset[1])))
except:
return False
profile["extruder"]["offsets"] = converted_offsets
return True
def _overwrite_profile(profile):
if not "id" in profile and not "name" in profile:
return None, None, make_response("Profile must contain either id or name")
elif not "name" in profile:
return None, None, make_response("Profile must contain a name")
try:
saved_profile = printerProfileManager.save(profile, allow_overwrite=False)
except Exception as e:
return None, None, make_response("Could not save profile: %s" % e.message)
return jsonify(dict(profile=_convert_profile(saved_profile)))

View file

@ -268,30 +268,30 @@ function PrinterProfilesViewModel() {
color: self.editorColor(), color: self.editorColor(),
model: self.editorModel(), model: self.editorModel(),
volume: { volume: {
width: self.editorVolumeWidth(), width: parseFloat(self.editorVolumeWidth()),
depth: self.editorVolumeDepth(), depth: parseFloat(self.editorVolumeDepth()),
height: self.editorVolumeHeight(), height: parseFloat(self.editorVolumeHeight()),
formFactor: self.editorVolumeFormFactor() formFactor: self.editorVolumeFormFactor()
}, },
heatedBed: self.editorHeatedBed(), heatedBed: self.editorHeatedBed(),
extruder: { extruder: {
count: self.editorExtruders(), count: parseInt(self.editorExtruders()),
offsets: [ offsets: [
[0.0, 0.0] [0.0, 0.0]
], ],
nozzleDiameter: self.editorNozzleDiameter() nozzleDiameter: parseFloat(self.editorNozzleDiameter())
}, },
axes: { axes: {
x: { x: {
speed: self.editorAxisXSpeed(), speed: parseInt(self.editorAxisXSpeed()),
inverted: self.editorAxisXInverted() inverted: self.editorAxisXInverted()
}, },
y: { y: {
speed: self.editorAxisYSpeed(), speed: parseInt(self.editorAxisYSpeed()),
inverted: self.editorAxisYInverted() inverted: self.editorAxisYInverted()
}, },
z: { z: {
speed: self.editorAxisZSpeed(), speed: parseInt(self.editorAxisZSpeed()),
inverted: self.editorAxisZInverted() inverted: self.editorAxisZInverted()
} }
} }

View file

@ -269,6 +269,20 @@ def dict_clean(a, b):
return result return result
def dict_contains_keys(a, b):
if not isinstance(a, dict) or not isinstance(b, dict):
return False
for k, v in a.iteritems():
if not k in b:
return False
elif isinstance(v, dict):
if not dict_contains_keys(v, b[k]):
return False
return True
class Object(object): class Object(object):
pass pass