From 07a9cec087385bb3facc6ffd4edb76b6f77bece3 Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Thu, 25 Oct 2018 06:59:36 +0000 Subject: [PATCH 1/2] Added concurrent application launch prevention --- securedrop_client/app.py | 23 +++++++++++++++- tests/test_app.py | 58 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/securedrop_client/app.py b/securedrop_client/app.py index faf9f1890..4602b2c7e 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -21,9 +21,10 @@ import os import signal import sys +import socket from argparse import ArgumentParser from sqlalchemy.orm import sessionmaker -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QMessageBox from PyQt5.QtCore import Qt, QTimer from logging.handlers import TimedRotatingFileHandler from securedrop_client import __version__ @@ -106,6 +107,24 @@ def arg_parser() -> ArgumentParser: return parser +def prevent_second_instance(app: QApplication, unique_name: str) -> None: + # Null byte triggers abstract namespace + IDENTIFIER = '\0' + app.applicationName() + unique_name + ALREADY_BOUND_ERRNO = 98 + + app.instance_binding = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + app.instance_binding.bind(IDENTIFIER) + except OSError as e: + if e.errno == ALREADY_BOUND_ERRNO: + err_dialog = QMessageBox() + err_dialog.setText(app.applicationName() + ' is already running.') + err_dialog.exec() + sys.exit() + else: + raise + + def start_app(args, qt_args) -> None: """ Create all the top-level assets for the application, set things up and @@ -129,6 +148,8 @@ def start_app(args, qt_args) -> None: app.setApplicationVersion(__version__) app.setAttribute(Qt.AA_UseHighDpiPixmaps) + prevent_second_instance(app, args.sdc_home) + gui = Window() app.setWindowIcon(load_icon(gui.icon)) app.setStyleSheet(load_css('sdclient.css')) diff --git a/tests/test_app.py b/tests/test_app.py index 28bfb950f..fcc32688c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,8 +7,8 @@ from PyQt5.QtWidgets import QApplication from unittest import mock from securedrop_client.app import ENCODING, excepthook, configure_logging, \ - start_app, arg_parser, DEFAULT_SDC_HOME, run, configure_signal_handlers - + start_app, arg_parser, DEFAULT_SDC_HOME, run, configure_signal_handlers, \ + prevent_second_instance app = QApplication([]) @@ -46,6 +46,57 @@ def test_configure_logging(safe_tmpdir): assert sys.excepthook == excepthook +@mock.patch('securedrop_client.app.sys.exit') +@mock.patch('securedrop_client.app.QMessageBox') +class TestSecondInstancePrevention(object): + @classmethod + def setup_class(cls): + cls.mock_app = mock.MagicMock() + cls.mock_app.applicationName = mock.MagicMock(return_value='sd') + + @staticmethod + def socket_mock_generator(already_bound_errno=98): + namespace = set() + + def kernel_bind(addr): + if addr in namespace: + error = OSError() + error.errno = already_bound_errno + raise error + else: + namespace.add(addr) + + socket_mock = mock.MagicMock() + socket_mock.socket().bind = mock.MagicMock(side_effect=kernel_bind) + return socket_mock + + def test_diff_name(self, mock_msgbox, mock_exit): + mock_socket = self.socket_mock_generator() + with mock.patch('securedrop_client.app.socket', new=mock_socket): + prevent_second_instance(self.mock_app, 'name1') + prevent_second_instance(self.mock_app, 'name2') + + mock_exit.assert_not_called() + + def test_same_name(self, mock_msgbox, mock_exit): + mock_socket = self.socket_mock_generator() + with mock.patch('securedrop_client.app.socket', new=mock_socket): + prevent_second_instance(self.mock_app, 'name1') + prevent_second_instance(self.mock_app, 'name1') + + mock_exit.assert_any_call() + + def test_unknown_kernel_error(self, mock_msgbox, mock_exit): + mock_socket = self.socket_mock_generator(131) # crazy unexpected error + with mock.patch('securedrop_client.app.socket', new=mock_socket): + try: + prevent_second_instance(self.mock_app, 'name1') + prevent_second_instance(self.mock_app, 'name1') + assert False # an unhandled exception should have occurred + except OSError: + assert True + + def test_start_app(safe_tmpdir): """ Ensure the expected things are configured and the application is started. @@ -60,6 +111,7 @@ def test_start_app(safe_tmpdir): mock.patch('securedrop_client.app.QApplication') as mock_app, \ mock.patch('securedrop_client.app.Window') as mock_win, \ mock.patch('securedrop_client.app.Client') as mock_client, \ + mock.patch('securedrop_client.app.socket'), \ mock.patch('securedrop_client.app.sys') as mock_sys, \ mock.patch('securedrop_client.app.sessionmaker', return_value=mock_session_class): @@ -128,6 +180,7 @@ def test_create_app_dir_permissions(tmpdir): mock.patch('securedrop_client.app.Window') as mock_win, \ mock.patch('securedrop_client.app.Client') as mock_client, \ mock.patch('securedrop_client.app.sys') as mock_sys, \ + mock.patch('securedrop_client.app.socket'), \ mock.patch('securedrop_client.app.sessionmaker', return_value=mock_session_class): @@ -180,6 +233,7 @@ def fake_known_args(): def test_signal_interception(): # check that initializing an app calls configure_signal_handlers with mock.patch('securedrop_client.app.QApplication'), \ + mock.patch('securedrop_client.app.socket'), \ mock.patch('sys.exit'), \ mock.patch('securedrop_client.models.make_engine'), \ mock.patch('securedrop_client.app.init'), \ From c228dae8487a431e21c6320665cfa51193738b7d Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Thu, 25 Oct 2018 07:54:44 +0000 Subject: [PATCH 2/2] Test tweaks --- tests/test_app.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index fcc32688c..ed7195e42 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -89,12 +89,9 @@ def test_same_name(self, mock_msgbox, mock_exit): def test_unknown_kernel_error(self, mock_msgbox, mock_exit): mock_socket = self.socket_mock_generator(131) # crazy unexpected error with mock.patch('securedrop_client.app.socket', new=mock_socket): - try: + with pytest.raises(OSError): prevent_second_instance(self.mock_app, 'name1') prevent_second_instance(self.mock_app, 'name1') - assert False # an unhandled exception should have occurred - except OSError: - assert True def test_start_app(safe_tmpdir): @@ -111,7 +108,7 @@ def test_start_app(safe_tmpdir): mock.patch('securedrop_client.app.QApplication') as mock_app, \ mock.patch('securedrop_client.app.Window') as mock_win, \ mock.patch('securedrop_client.app.Client') as mock_client, \ - mock.patch('securedrop_client.app.socket'), \ + mock.patch('securedrop_client.app.prevent_second_instance'), \ mock.patch('securedrop_client.app.sys') as mock_sys, \ mock.patch('securedrop_client.app.sessionmaker', return_value=mock_session_class): @@ -180,7 +177,7 @@ def test_create_app_dir_permissions(tmpdir): mock.patch('securedrop_client.app.Window') as mock_win, \ mock.patch('securedrop_client.app.Client') as mock_client, \ mock.patch('securedrop_client.app.sys') as mock_sys, \ - mock.patch('securedrop_client.app.socket'), \ + mock.patch('securedrop_client.app.prevent_second_instance'), \ mock.patch('securedrop_client.app.sessionmaker', return_value=mock_session_class): @@ -233,7 +230,7 @@ def fake_known_args(): def test_signal_interception(): # check that initializing an app calls configure_signal_handlers with mock.patch('securedrop_client.app.QApplication'), \ - mock.patch('securedrop_client.app.socket'), \ + mock.patch('securedrop_client.app.prevent_second_instance'), \ mock.patch('sys.exit'), \ mock.patch('securedrop_client.models.make_engine'), \ mock.patch('securedrop_client.app.init'), \