utils.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org
  3. #
  4. # Copyright (C) 2012 Agnieszka Kostrzewa <agnieszka.kostrzewa@gmail.com>
  5. # Copyright (C) 2012 Marek Marczykowski-Górecki
  6. # <marmarek@invisiblethingslab.com>
  7. # Copyright (C) 2017 Wojtek Porczyk <woju@invisiblethingslab.com>
  8. # Copyright (C) 2020 Marta Marczykowska-Górecka
  9. # <marmarta@invisiblethingslab.com>
  10. #
  11. # This program is free software: you can redistribute it and/or modify
  12. # it under the terms of the GNU General Public License as published by
  13. # the Free Software Foundation, either version 2 of the License, or
  14. # (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU General Public License
  22. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. #
  24. import itertools
  25. import os
  26. import re
  27. import qubesadmin
  28. import traceback
  29. import asyncio
  30. from contextlib import suppress
  31. import sys
  32. import qasync
  33. from qubesadmin import events
  34. from PyQt5 import QtWidgets, QtCore, QtGui # pylint: disable=import-error
  35. # important usage note: which initialize_widget should I use?
  36. # - if you want a list of VMs, use initialize_widget_with_vms, optionally
  37. # adding a property if you want to handle qubesadmin.DEFAULT and the
  38. # current (potentially default) value
  39. # - if you want a list of labels or kernals, use
  40. # initialize_widget_with_kernels/labels
  41. # - list of some things, but associated with a definite property (optionally
  42. # with qubesadmin.DEFAULT) - initialize_widget_for_property
  43. # - list of some things, not associated with a property, but still having a
  44. # default - initialize_widget_with_default
  45. # - just a list, no properties or defaults, just a nice list with a "current"
  46. # value - initialize_widget
  47. def is_internal(vm):
  48. """checks if the VM is either an AdminVM or has the 'internal' features"""
  49. return (vm.klass == 'AdminVM'
  50. or vm.features.get('internal', False))
  51. def translate(string):
  52. """helper function for translations"""
  53. return QtCore.QCoreApplication.translate(
  54. "ManagerUtils", string)
  55. class SizeSpinBox(QtWidgets.QSpinBox):
  56. """A SpinBox subclass with extended handling for sizes in MB and GB"""
  57. # pylint: disable=invalid-name, no-self-use
  58. def __init__(self, *args, **kwargs):
  59. super(SizeSpinBox, self).__init__(*args, **kwargs)
  60. self.pattern = r'(\d+\.?\d?) ?(GB|MB)'
  61. self.regex = re.compile(self.pattern)
  62. self.validator = QtGui.QRegExpValidator(QtCore.QRegExp(
  63. self.pattern), self)
  64. def textFromValue(self, v: int) -> str:
  65. if v > 1024:
  66. return '{:.1f} GB'.format(v / 1024)
  67. return '{} MB'.format(v)
  68. def validate(self, text: str, pos: int):
  69. return self.validator.validate(text, pos)
  70. def valueFromText(self, text: str) -> int:
  71. value, unit = self.regex.fullmatch(text.strip()).groups()
  72. if unit == 'GB':
  73. multiplier = 1024
  74. else:
  75. multiplier = 1
  76. return int(float(value) * multiplier)
  77. def get_boolean_feature(vm, feature_name):
  78. """heper function to get a feature converted to a Bool if it does exist.
  79. Necessary because of the true/false in features being coded as 1/empty
  80. string."""
  81. result = vm.features.get(feature_name, None)
  82. if result is not None:
  83. result = bool(result)
  84. return result
  85. def did_widget_selection_change(widget):
  86. """a simple heuristic to check if the widget text contains appropriately
  87. translated 'current'"""
  88. return not translate(" (current)") in widget.currentText()
  89. def initialize_widget(widget, choices, selected_value=None,
  90. icon_getter=None, add_current_label=True):
  91. """
  92. populates widget (ListBox or ComboBox) with items. Previous widget contents
  93. are erased.
  94. :param widget: QListBox or QComboBox; must support addItem and findText
  95. :param choices: list of tuples (text, value) to use to populate widget.
  96. text should be a string, value can be of any type, including None
  97. :param selected_value: initial widget value
  98. :param icon_getter: function of value that returns desired icon
  99. :param add_current_label: if initial value should be labelled as (current)
  100. :return:
  101. """
  102. widget.clear()
  103. selected_item = None
  104. for (name, value) in choices:
  105. if value == selected_value:
  106. selected_item = name
  107. if icon_getter is not None:
  108. widget.addItem(icon_getter(value), name, userData=value)
  109. else:
  110. widget.addItem(name, userData=value)
  111. if selected_item is not None:
  112. widget.setCurrentIndex(widget.findText(selected_item))
  113. else:
  114. widget.addItem(str(selected_value), selected_value)
  115. widget.setCurrentIndex(widget.findText(str(selected_value)))
  116. if add_current_label:
  117. widget.setItemText(widget.currentIndex(),
  118. widget.currentText() + translate(" (current)"))
  119. def initialize_widget_for_property(
  120. widget, choices, holder, property_name, allow_default=False,
  121. icon_getter=None, add_current_label=True):
  122. """
  123. populates widget (ListBox or ComboBox) with items, based on a listed
  124. property. Supports discovering the system default for the given property
  125. and handling qubesadmin.DEFAULT special value. Value of holder.property
  126. will be set as current item. Previous widget contents are erased.
  127. :param widget: QListBox or QComboBox; must support addItem and findText
  128. :param choices: list of tuples (text, value) to use to populate widget.
  129. text should be a string, value can be of any type, including None
  130. :param holder: object to use as property_name's holder
  131. :param property_name: name of the property
  132. :param allow_default: boolean, should a position with qubesadmin.DEFAULT
  133. be added; default False
  134. :param icon_getter: a function applied to values (from choices) that
  135. returns a QIcon to be used as a item icon; default None
  136. :param add_current_label: if initial value should be labelled as (current)
  137. :return:
  138. """
  139. if allow_default:
  140. default_property = holder.property_get_default(property_name)
  141. if default_property is None:
  142. default_property = "none"
  143. choices.append(
  144. (translate("default ({})").format(default_property),
  145. qubesadmin.DEFAULT))
  146. # calculate current (can be default)
  147. if holder.property_is_default(property_name):
  148. current_value = qubesadmin.DEFAULT
  149. else:
  150. current_value = getattr(holder, property_name)
  151. initialize_widget(widget,
  152. choices,
  153. selected_value=current_value,
  154. icon_getter=icon_getter,
  155. add_current_label=add_current_label)
  156. # TODO: improvement: add optional icon support
  157. def initialize_widget_with_vms(
  158. widget, qubes_app, filter_function=(lambda x: True),
  159. allow_none=False, holder=None, property_name=None,
  160. allow_default=False, allow_internal=False):
  161. """
  162. populates widget (ListBox or ComboBox) with vm items, optionally based on
  163. a given property. Supports discovering the system default for the property
  164. and handling qubesadmin.DEFAULT special value. Value of holder.property
  165. will be set as current item. Previous widget contents are erased.
  166. :param widget: QListBox or QComboBox; must support addItem and findText
  167. :param qubes_app: Qubes() object
  168. :param filter_function: function used to filter vms; optional
  169. :param allow_none: should a None option be added; default False
  170. :param holder: object to use as property_name's holder
  171. :param property_name: name of the property
  172. :param allow_default: should a position with qubesadmin.DEFAULT be added;
  173. default False
  174. :param allow_internal: should AdminVMs and vms with feature 'internal' be
  175. used
  176. :return:
  177. """
  178. choices = []
  179. for vm in qubes_app.domains:
  180. if not allow_internal and is_internal(vm):
  181. continue
  182. if not filter_function(vm):
  183. continue
  184. choices.append((vm.name, vm))
  185. if allow_none:
  186. choices.append((translate("(none)"), None))
  187. if holder is None:
  188. initialize_widget(widget,
  189. choices,
  190. selected_value=choices[0][1],
  191. add_current_label=False)
  192. else:
  193. initialize_widget_for_property(
  194. widget=widget, choices=choices, holder=holder,
  195. property_name=property_name, allow_default=allow_default)
  196. def initialize_widget_with_default(
  197. widget, choices, add_none=False, add_qubes_default=False,
  198. mark_existing_as_default=False, default_value=None):
  199. """
  200. populates widget (ListBox or ComboBox) with items. Used when there is no
  201. corresponding property, but support for special qubesadmin.DEFAULT value
  202. is still needed.
  203. :param widget: QListBox or QComboBox; must support addItem and findText
  204. :param choices: list of tuples (text, value) to use to populate widget.
  205. text should be a string, value can be of any type, including None
  206. :param add_none: should a 'None' position be added
  207. :param add_qubes_default: should a qubesadmin.DEFAULT position be added
  208. (requires default_value to be set to something meaningful)
  209. :param mark_existing_as_default: should an existing value be marked
  210. as default. If used with conjuction with add_qubes_default, the
  211. default_value listed will be replaced by qubesadmin.DEFAULT
  212. :param default_value: what value should be used as the default
  213. :return:
  214. """
  215. added_existing = False
  216. if mark_existing_as_default:
  217. existing_default = [item for item in choices
  218. if item[1] == default_value]
  219. if existing_default:
  220. choices = [item for item in choices if item not in existing_default]
  221. if add_qubes_default:
  222. # if for some reason (e.g. storage pools) we want to mark an
  223. # actual value as default and replace it with qubesadmin.DEFAULT
  224. default_value = qubesadmin.DEFAULT
  225. choices.insert(
  226. 0, (translate("default ({})").format(existing_default[0][0]),
  227. default_value))
  228. added_existing = True
  229. elif add_qubes_default:
  230. choices.insert(0, (translate("default ({})").format(default_value),
  231. qubesadmin.DEFAULT))
  232. if add_none:
  233. if mark_existing_as_default and default_value is None and \
  234. not added_existing:
  235. choices.append((translate("default (none)"), None))
  236. else:
  237. choices.append((translate("(none)"), None))
  238. if add_qubes_default:
  239. selected_value = qubesadmin.DEFAULT
  240. elif mark_existing_as_default:
  241. selected_value = default_value
  242. else:
  243. selected_value = choices[0][1]
  244. initialize_widget(
  245. widget=widget, choices=choices, selected_value=selected_value,
  246. add_current_label=False)
  247. def initialize_widget_with_kernels(
  248. widget, qubes_app, allow_none=False, holder=None,
  249. property_name=None, allow_default=False):
  250. """
  251. populates widget (ListBox or ComboBox) with kernel items, based on a given
  252. property. Supports discovering the system default for the property
  253. and handling qubesadmin.DEFAULT special value. Value of holder.property
  254. will be set as current item. Previous widget contents are erased.
  255. :param widget: QListBox or QComboBox; must support addItem and findText
  256. :param qubes_app: Qubes() object
  257. :param allow_none: should a None item be added
  258. :param holder: object to use as property_name's holder
  259. :param property_name: name of the property
  260. :param allow_default: should a qubesadmin.DEFAULT item be added
  261. :return:
  262. """
  263. kernels = [kernel.vid for kernel in qubes_app.pools['linux-kernel'].volumes]
  264. kernels = sorted(kernels, key=KernelVersion)
  265. choices = [(kernel, kernel) for kernel in kernels]
  266. if allow_none:
  267. choices.append((translate("(none)"), None))
  268. initialize_widget_for_property(
  269. widget=widget, choices=choices, holder=holder,
  270. property_name=property_name, allow_default=allow_default)
  271. def initialize_widget_with_labels(widget, qubes_app,
  272. holder=None, property_name='label'):
  273. """
  274. populates widget (ListBox or ComboBox) with label items, optionally based
  275. on a given property. Value of holder.property will be set as current item.
  276. Previous widget contents are erased.
  277. :param widget: QListBox or QComboBox; must support addItem and findText
  278. :param qubes_app: Qubes() object
  279. :param holder: object to use as property_name's holder; can be None
  280. :param property_name: name of the property
  281. :return:
  282. """
  283. labels = sorted(qubes_app.labels.values(), key=lambda l: l.index)
  284. choices = [(label.name, label) for label in labels]
  285. icon_getter = (lambda label:
  286. QtGui.QIcon.fromTheme(label.icon))
  287. if holder:
  288. initialize_widget_for_property(widget=widget,
  289. choices=choices,
  290. holder=holder,
  291. property_name=property_name,
  292. icon_getter=icon_getter)
  293. else:
  294. initialize_widget(widget=widget,
  295. choices=choices,
  296. selected_value=labels[0],
  297. icon_getter=icon_getter,
  298. add_current_label=False)
  299. class KernelVersion: # pylint: disable=too-few-public-methods
  300. # Cannot use distutils.version.LooseVersion, because it fails at handling
  301. # versions that have no numbers in them
  302. def __init__(self, string):
  303. self.string = string
  304. self.groups = re.compile(r'(\d+)').split(self.string)
  305. def __lt__(self, other):
  306. for (self_content, other_content) in itertools.zip_longest(
  307. self.groups, other.groups):
  308. if self_content == other_content:
  309. continue
  310. if self_content is None:
  311. return True
  312. if other_content is None:
  313. return False
  314. if self_content.isdigit() and other_content.isdigit():
  315. return int(self_content) < int(other_content)
  316. return self_content < other_content
  317. def is_debug():
  318. return os.getenv('QUBES_MANAGER_DEBUG', '') not in ('', '0')
  319. def debug(*args, **kwargs):
  320. if not is_debug():
  321. return
  322. print(*args, **kwargs)
  323. def get_path_from_vm(vm, service_name):
  324. """
  325. Displays a file/directory selection window for the given VM.
  326. :param vm: vm from which to select path
  327. :param service_name: qubes.SelectFile or qubes.SelectDirectory
  328. :return: path to file, checked for validity
  329. """
  330. path_re = re.compile(r"[a-zA-Z0-9/:.,_+=() -]*")
  331. path_max_len = 512
  332. if not vm:
  333. return None
  334. stdout, _stderr = vm.run_service_for_stdio(service_name)
  335. stdout = stdout.strip()
  336. untrusted_path = stdout.decode(encoding='ascii')[:path_max_len]
  337. if not untrusted_path:
  338. return None
  339. if path_re.fullmatch(untrusted_path):
  340. assert '../' not in untrusted_path
  341. assert '\0' not in untrusted_path
  342. return untrusted_path.strip()
  343. raise ValueError(QtCore.QCoreApplication.translate(
  344. "ManagerUtils", 'Unexpected characters in path.'))
  345. def format_dependencies_list(dependencies):
  346. """Given a list of tuples representing properties, formats them in
  347. a readable list."""
  348. list_text = ""
  349. for (holder, prop) in dependencies:
  350. if holder is None:
  351. list_text += QtCore.QCoreApplication.translate(
  352. "ManagerUtils", "- Global property <b>{}</b> <br>").format(prop)
  353. else:
  354. list_text += QtCore.QCoreApplication.translate(
  355. "ManagerUtils", "- <b>{0}</b> for qube <b>{1}</b> <br>").format(
  356. prop, holder.name)
  357. return list_text
  358. def loop_shutdown():
  359. pending = asyncio.Task.all_tasks()
  360. for task in pending:
  361. with suppress(asyncio.CancelledError):
  362. task.cancel()
  363. # Bases on the original code by:
  364. # Copyright (c) 2002-2007 Pascal Varet <p.varet@gmail.com>
  365. def handle_exception(exc_type, exc_value, exc_traceback):
  366. filename, line, _, _ = traceback.extract_tb(exc_traceback).pop()
  367. filename = os.path.basename(filename)
  368. error = "%s: %s" % (exc_type.__name__, exc_value)
  369. strace = ""
  370. stacktrace = traceback.extract_tb(exc_traceback)
  371. while stacktrace:
  372. (filename, line, func, txt) = stacktrace.pop()
  373. strace += "----\n"
  374. strace += "line: %s\n" % txt
  375. strace += "func: %s\n" % func
  376. strace += "line no.: %d\n" % line
  377. strace += "file: %s\n" % filename
  378. msg_box = QtWidgets.QMessageBox()
  379. msg_box.setDetailedText(strace)
  380. msg_box.setIcon(QtWidgets.QMessageBox.Critical)
  381. msg_box.setWindowTitle(QtCore.QCoreApplication.translate(
  382. "ManagerUtils", "Houston, we have a problem..."))
  383. msg_box.setText(QtCore.QCoreApplication.translate(
  384. "ManagerUtils", "Whoops. A critical error has occured. "
  385. "This is most likely a bug in Qubes Manager.<br><br>"
  386. "<b><i>{0}</i></b><br/>at line <b>{1}</b><br/>of file "
  387. "{2}.<br/><br/>").format(error, line, filename))
  388. msg_box.exec_()
  389. def run_asynchronous(window_class):
  390. qt_app = QtWidgets.QApplication(sys.argv)
  391. translator = QtCore.QTranslator(qt_app)
  392. locale = QtCore.QLocale.system().name()
  393. i18n_dir = os.path.join(
  394. os.path.dirname(os.path.realpath(__file__)),
  395. 'i18n')
  396. translator.load("qubesmanager_{!s}.qm".format(locale), i18n_dir)
  397. qt_app.installTranslator(translator)
  398. QtCore.QCoreApplication.installTranslator(translator)
  399. qt_app.setOrganizationName("The Qubes Project")
  400. qt_app.setOrganizationDomain("http://qubes-os.org")
  401. qt_app.lastWindowClosed.connect(loop_shutdown)
  402. qubes_app = qubesadmin.Qubes()
  403. loop = qasync.QEventLoop(qt_app)
  404. asyncio.set_event_loop(loop)
  405. dispatcher = events.EventsDispatcher(qubes_app)
  406. window = window_class(qt_app, qubes_app, dispatcher)
  407. if hasattr(window, "setup_application"):
  408. window.setup_application()
  409. window.show()
  410. try:
  411. loop.run_until_complete(
  412. asyncio.ensure_future(dispatcher.listen_for_events()))
  413. except asyncio.CancelledError:
  414. pass
  415. except Exception: # pylint: disable=broad-except
  416. loop_shutdown()
  417. exc_type, exc_value, exc_traceback = sys.exc_info()[:3]
  418. handle_exception(exc_type, exc_value, exc_traceback)
  419. def run_synchronous(window_class):
  420. qt_app = QtWidgets.QApplication(sys.argv)
  421. translator = QtCore.QTranslator(qt_app)
  422. locale = QtCore.QLocale.system().name()
  423. i18n_dir = os.path.join(
  424. os.path.dirname(os.path.realpath(__file__)),
  425. 'i18n')
  426. translator.load("qubesmanager_{!s}.qm".format(locale), i18n_dir)
  427. qt_app.installTranslator(translator)
  428. QtCore.QCoreApplication.installTranslator(translator)
  429. qt_app.setOrganizationName("The Qubes Project")
  430. qt_app.setOrganizationDomain("http://qubes-os.org")
  431. sys.excepthook = handle_exception
  432. qubes_app = qubesadmin.Qubes()
  433. window = window_class(qt_app, qubes_app)
  434. if hasattr(window, "setup_application"):
  435. window.setup_application()
  436. window.show()
  437. qt_app.exec_()
  438. qt_app.exit()