Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] read/write: (Un)pickle only on user confirmation #127

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
136 changes: 127 additions & 9 deletions orangecanvas/application/canvasmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from ..scheme import Scheme, IncompatibleChannelTypeError, SchemeNode
from ..scheme import readwrite
from ..scheme.readwrite import UnknownWidgetDefinition
from ..scheme.node import UserMessage
from ..gui.dropshadow import DropShadowFrame
from ..gui.dock import CollapsibleDockWidget
from ..gui.quickhelp import QuickHelpTipEvent
Expand Down Expand Up @@ -1231,15 +1232,85 @@ def new_scheme_from_contents_and_path(
-------
workflow: Optional[Scheme]
"""
new_scheme = config.workflow_constructor(parent=self)
new_scheme.set_runtime_env(
"basedir", os.path.abspath(os.path.dirname(path)))
errors = [] # type: List[Exception]
try:
def warn(warning):
if isinstance(warning, readwrite.PickleDataWarning):
raise warning

def load(fileobj, warning_handler=None,
data_deserializer=readwrite.default_deserializer):
new_scheme = config.workflow_constructor()
new_scheme.set_runtime_env(
"basedir", os.path.abspath(os.path.dirname(path)))
errors = [] # type: List[Exception]
new_scheme.load_from(
fileobj, registry=self.widget_registry,
error_handler=errors.append
error_handler=errors.append, warning_handler=warning_handler,
data_deserializer=data_deserializer
)
return new_scheme, errors

basename = os.path.basename(path)
pos = -1
try:
pos = fileobj.tell()
new_scheme, errors = load(
fileobj, warning_handler=warn,
data_deserializer=readwrite.default_deserializer
)
except (readwrite.UnsupportedPickleFormatError,
readwrite.PickleDataWarning):
mbox = QMessageBox(
self, icon=QMessageBox.Warning,
windowTitle=self.tr("Security Warning"),
text=self.tr(
"The file {basename} contains pickled data that can run "
"arbitrary commands on this computer.\n"
"Would you like to load the unsafe content anyway?"
).format(basename=basename),
informativeText=self.tr(
"Only select <b>Load unsafe</b> if you trust the source "
"of the file."
),
textFormat=Qt.PlainText,
standardButtons=QMessageBox.Yes | QMessageBox.No |
QMessageBox.Abort
)
mbox.setDefaultButton(QMessageBox.Abort)
yes = mbox.button(QMessageBox.Yes)
yes.setText(self.tr("Load unsafe"))
yes.setToolTip(self.tr(
"Load the complete file. Only select this if you trust "
"the origin of the file."
))
no = mbox.button(QMessageBox.No)
no.setText(self.tr("Load partial"))
no.setToolTip(self.tr(
"Load the file only partially, striping out all the "
"unsafe content."
))
res = mbox.exec()
if res == QMessageBox.Abort:
return None
elif res == QMessageBox.Yes: # load with unsafe data
data_deserializer = readwrite.default_deserializer_with_pickle_fallback
elif res == QMessageBox.No: # load but discard unsafe data
data_deserializer = readwrite.default_deserializer
else:
assert False
fileobj.seek(pos, os.SEEK_SET)
new_scheme, errors = load(
fileobj, warning_handler=None,
data_deserializer=data_deserializer
)
for e in list(errors):
if isinstance(e, readwrite.UnsupportedPickleFormatError):
if e.node is not None and e.node in new_scheme.nodes:
e.node.set_state_message(
UserMessage(
"Did not restore settings", UserMessage.Warning,
message_id="-properties-restore-error-data",
))
errors.remove(e)
except Exception: # pylint: disable=broad-except
log.exception("")
message_critical(
Expand All @@ -1262,6 +1333,7 @@ def new_scheme_from_contents_and_path(
details=details,
parent=self,
)
new_scheme.setParent(self)
return new_scheme

def check_requires(self, fileobj: IO) -> bool:
Expand Down Expand Up @@ -1506,9 +1578,55 @@ def save_scheme_to(self, scheme, filename):
# First write the scheme to a buffer so we don't truncate an
# existing scheme file if `scheme.save_to` raises an error.
buffer = io.BytesIO()
scheme.set_runtime_env("basedir", os.path.abspath(dirname))
try:
scheme.set_runtime_env("basedir", os.path.abspath(dirname))
scheme.save_to(buffer, pretty=True, pickle_fallback=True)
try:
scheme.save_to(
buffer, pretty=True, data_serializer=readwrite.default_serializer
)
except (readwrite.UnserializableTypeError,
readwrite.UnserializableValueError):
mb = QMessageBox(
parent=self, windowTitle=self.tr("Unsafe contents"),
icon=QMessageBox.Warning,
text=self.tr(
"The workflow contains parameters that cannot be "
"safely deserialized.\n"
"Would you like to save a partial workflow anyway."),
informativeText=self.tr(
"Workflow structure will be saved but some node "
"parameters will be lost."
),
standardButtons=QMessageBox.Discard | QMessageBox.Ignore |
QMessageBox.Abort
)
mb.setEscapeButton(QMessageBox.Abort)
mb.setDefaultButton(QMessageBox.Discard)
b = mb.button(QMessageBox.Ignore)
b.setText(self.tr("Save anyway"))
b.setToolTip(self.tr(
"Loading such a workflow will require explicit user "
"confirmation."))
b = mb.button(QMessageBox.Discard)
b.setText(self.tr("Discard unsafe content"))
b.setToolTip(self.tr("The saved workflow will not be complete. "
"Some parameters will not be restored"))
res = mb.exec()
buffer.truncate(0)
if res == QMessageBox.Abort:
return False
if res == QMessageBox.Discard:
def serializer(node):
try:
return readwrite.default_serializer(node)
except Exception:
return None
else:
serializer = readwrite.default_serializer_with_pickle_fallback
scheme.save_to(
buffer, pretty=True,
data_serializer=serializer
)
except Exception:
log.error("Error saving %r to %r", scheme, filename, exc_info=True)
message_critical(
Expand Down Expand Up @@ -2624,7 +2742,7 @@ def collectall(
IncompatibleChannelTypeError))
)
contents = []
if missing_node_defs is not None:
if missing_node_defs:
contents.extend([
"Missing node definitions:",
*[" \N{BULLET} " + e.args[0] for e in missing_node_defs],
Expand Down
65 changes: 64 additions & 1 deletion orangecanvas/application/tests/test_mainwindow.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import os
import tempfile
from contextlib import contextmanager
from unittest.mock import patch

from AnyQt.QtGui import QWhatsThisClickedEvent
from AnyQt.QtWidgets import QToolButton, QDialog, QMessageBox, QApplication

from .. import addons
from ..outputview import TextStream
from ...scheme import SchemeTextAnnotation, SchemeLink
from ...scheme import SchemeTextAnnotation, SchemeLink, SchemeNode, Scheme
from ...gui.quickhelp import QuickHelpTipEvent, QuickHelp
from ...utils.shtools import temp_named_file
from ...utils.pickle import swp_name
Expand Down Expand Up @@ -177,6 +178,68 @@ def test_save(self):
w.save_scheme()
self.assertEqual(w.current_document().path(), self.filename)

@contextmanager
def patch_messagebox_exec(self, return_value):
with patch("AnyQt.QtWidgets.QMessageBox.exec",
return_value=return_value) as f:
with patch("AnyQt.QtWidgets.QMessageBox.exec_",
f):
yield f

def test_save_unsafe_warn(self):
w = self.w
doc = w.current_document()
doc.setPath(self.filename)
node = SchemeNode(self.registry.widget("one"))
node.properties = {"a": object()}
doc.addNode(node)

def contents():
with open(self.filename, "r", encoding="utf-8") as f:
return f.read()
with self.patch_messagebox_exec(QMessageBox.Abort) as f:
w.save_scheme()
f.assert_called_with()
self.assertEqual(contents(), "")
with self.patch_messagebox_exec(QMessageBox.Discard) as f:
w.save_scheme()
f.assert_called_with()
self.assertNotIn("pickle", contents())

with self.patch_messagebox_exec(QMessageBox.Ignore) as f:
w.save_scheme()
f.assert_called_with()
self.assertIn("pickle", contents())

def test_load_unsafe_ask(self):
w = self.w
workflow = Scheme()
node = SchemeNode(self.registry.widget("one"))
node.properties = {"a": object()}
workflow.add_node(node)
with open(self.filename, "wb") as f:
workflow.save_to(f, pickle_fallback=True)

with self.patch_messagebox_exec(QMessageBox.Abort) as f:
w.load_scheme(self.filename)
f.assert_called_with()
self.assertEqual(len(w.current_document().scheme().nodes), 0)
self.assertTrue(w.is_transient())

with self.patch_messagebox_exec(return_value=QMessageBox.No) as f:
w.load_scheme(self.filename)
f.assert_called_with()
workflow = w.current_document().scheme()
self.assertEqual(len(workflow.nodes), 1)
self.assertEqual(workflow.nodes[0].properties, {})

with self.patch_messagebox_exec(QMessageBox.Yes) as f:
w.load_scheme(self.filename)
f.assert_called_with()
workflow = w.current_document().scheme()
self.assertEqual(len(workflow.nodes), 1)
self.assertEqual(workflow.nodes[0].properties["a"].__class__, object)

def test_save_swp(self):
w = self.w
swpname = swp_name(w)
Expand Down
Loading