diff --git a/orangecanvas/application/canvasmain.py b/orangecanvas/application/canvasmain.py
index cd044399a..898d8c2ff 100644
--- a/orangecanvas/application/canvasmain.py
+++ b/orangecanvas/application/canvasmain.py
@@ -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
@@ -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 Load unsafe 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(
@@ -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:
@@ -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(
@@ -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],
diff --git a/orangecanvas/application/tests/test_mainwindow.py b/orangecanvas/application/tests/test_mainwindow.py
index eb91ae456..0014250f2 100644
--- a/orangecanvas/application/tests/test_mainwindow.py
+++ b/orangecanvas/application/tests/test_mainwindow.py
@@ -1,5 +1,6 @@
import os
import tempfile
+from contextlib import contextmanager
from unittest.mock import patch
from AnyQt.QtGui import QWhatsThisClickedEvent
@@ -7,7 +8,7 @@
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
@@ -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)
diff --git a/orangecanvas/scheme/readwrite.py b/orangecanvas/scheme/readwrite.py
index b7d0a915e..4a9e5d6e4 100644
--- a/orangecanvas/scheme/readwrite.py
+++ b/orangecanvas/scheme/readwrite.py
@@ -2,20 +2,18 @@
Scheme save/load routines.
"""
+import io
import numbers
-import sys
-import types
import warnings
import base64
import binascii
-import itertools
+import pickle
+from functools import partial
from xml.etree.ElementTree import TreeBuilder, Element, ElementTree, parse
-from collections import defaultdict
-from itertools import chain, count
+from itertools import chain
-import pickle
import json
import pprint
@@ -25,7 +23,7 @@
import logging
from typing import (
- NamedTuple, Dict, Tuple, List, Union, Any, Optional, AnyStr, IO
+ NamedTuple, Dict, Tuple, List, Union, Any, Optional, AnyStr, IO, Callable
)
from . import SchemeNode, SchemeLink
@@ -45,6 +43,14 @@ class UnknownWidgetDefinition(Exception):
pass
+class DeserializationWarning(UserWarning):
+ node = None # type: Optional[SchemeNode]
+
+
+class PickleDataWarning(DeserializationWarning):
+ pass
+
+
def _ast_parse_expr(source):
# type: (str) -> ast.Expression
node = ast.parse(source, "