diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 1a768bcf4..4c75d5b30 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -2,10 +2,12 @@ import logging import os import shutil +import subprocess import tarfile +from enum import Enum from io import BytesIO from shlex import quote -from tempfile import mkdtemp +from tempfile import TemporaryDirectory, mkdtemp from typing import Callable, List, Optional from PyQt5.QtCore import QObject, QProcess, pyqtSignal @@ -41,6 +43,9 @@ class Export(QObject): _DISK_ENCRYPTION_KEY_NAME = "encryption_key" _DISK_EXPORT_DIR = "export_data" + _WHISTLEFLOW_METADATA = {} + _WHISTLEFLOW_PREFLIGHT_FN = "whistleflow-preflight.sd-export" + # Emit export states export_state_changed = pyqtSignal(object) @@ -53,6 +58,11 @@ class Export(QObject): print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) + whistleflow_preflight_check_failed = pyqtSignal(object) + whistleflow_preflight_check_succeeded = pyqtSignal() + whistleflow_call_failure = pyqtSignal(object) + whistleflow_call_success = pyqtSignal() + process = None # Optional[QProcess] tmpdir = None # mkdtemp directory must be cleaned up when QProcess completes @@ -328,7 +338,12 @@ def print(self, filepaths: List[str]) -> None: self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) def _create_archive( - self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] + self, + archive_dir: str, + archive_fn: str, + metadata: dict, + filepaths: List[str] = [], + whistleflow: bool = False, ) -> str: """ Create the archive to be sent to the Export VM. @@ -338,6 +353,7 @@ def _create_archive( archive_fn (str): The name of the archive file. metadata (dict): The dictionary containing metadata to add to the archive. filepaths (List[str]): The list of files to add to the archive. + whistleflow (bool): Indicates if this is a whistleflow export Returns: str: The path to newly-created archive file. @@ -351,6 +367,11 @@ def _create_archive( # extra care must be taken to prevent name collisions. is_one_of_multiple_files = len(filepaths) > 1 missing_count = 0 + # If this is an export to Whistleflow, we want to create a directory + # called source_name even if it only contains a single file. + # whistleflow-view relies on this directory to pre-populate the + # source name field in the send-to-giant form. + prevent_name_collisions = is_one_of_multiple_files or whistleflow for filepath in filepaths: if not (os.path.exists(filepath)): missing_count += 1 @@ -361,9 +382,7 @@ def _create_archive( # so this shouldn't be reachable logger.warning("File not found at specified filepath, skipping") else: - self._add_file_to_archive( - archive, filepath, prevent_name_collisions=is_one_of_multiple_files - ) + self._add_file_to_archive(archive, filepath, prevent_name_collisions) if missing_count == len(filepaths) and missing_count > 0: # Context manager will delete archive even if an exception occurs # since the archive is in a TemporaryDirectory @@ -412,3 +431,98 @@ def _add_file_to_archive( arcname = os.path.join("export_data", parent_name, filename) archive.add(filepath, arcname=arcname, recursive=False) + + # below whistleflow functions rescued from the old export file + + def _export_archive_to_whistleflow(cls, archive_path: str) -> Optional[ExportStatus]: + """ + Clone of _export_archive which sends the archive to the Whistleflow VM. + """ + try: + output = subprocess.check_output( + [ + quote("qrexec-client-vm"), + quote("--"), + quote("whistleflow-view"), + quote("qubes.Filecopy"), + quote("/usr/lib/qubes/qfile-agent"), + quote(archive_path), + ], + stderr=subprocess.STDOUT, + ) + result = output.decode("utf-8").strip() + + # No status is returned for successful `disk`, `printer-test`, and `print` calls. + # This will change in a future release of sd-export. + if result: + return ExportStatus(result) + else: + return None + except ValueError as e: + logger.debug(f"Export subprocess returned unexpected value: {e}") + raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) + except subprocess.CalledProcessError as e: + logger.error("Subprocess failed") + logger.debug(f"Subprocess failed: {e}") + raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) + + def send_files_to_whistleflow(self, filename: str, filepaths: List[str]) -> None: + """ + Clone of send_file_to_usb_device, but for Whistleflow. + """ + with TemporaryDirectory() as temp_dir: + try: + logger.debug("beginning export") + self._run_whistleflow_export(temp_dir, filename, filepaths) + self.whistleflow_call_success.emit() + logger.debug("Export successful") + except ExportError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.whistleflow_call_failure.emit(e) + + self.export_completed.emit(filepaths) + + def _run_whistleflow_view_test(self) -> None: + # TODO fill this in + logger.info("Running dummy whistleflow view test") + + def _run_whistleflow_export( + self, archive_dir: str, filename: str, filepaths: List[str] + ) -> None: + """ + Run disk-test. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + + Raises: + ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. + """ + metadata = self._WHISTLEFLOW_METADATA.copy() + archive_path = self._create_archive( + archive_dir, filename, metadata, filepaths, whistleflow=True + ) + + status = self._export_archive_to_whistleflow(archive_path) + if status: + raise ExportError(status) + + def run_whistleflow_preflight_checks(self) -> None: + """ + Run dummy preflight test + """ + try: + logger.debug("beginning whistleflow preflight checks") + self._run_whistleflow_view_test() + + logger.debug("completed preflight checks: success") + self.whistleflow_preflight_check_succeeded.emit() + except ExportError as e: + logger.debug("completed preflight checks: failure") + self.whistleflow_preflight_check_failed.emit(e) + + +class ExportDestination(Enum): + USB = "USB" + WHISTLEFLOW = "WHISTLEFLOW" diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 449c4f4fe..956e4e8d1 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -16,14 +16,13 @@ from securedrop_client import state from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source -from securedrop_client.export import ExportDestination -from securedrop_client.export import Export +from securedrop_client.export import Export, ExportDestination from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.conversation import ( PrintTranscriptDialog as PrintConversationTranscriptDialog, ) -from securedrop_client.gui.conversation.export.whistleflow_dialog import WhistleflowDialog from securedrop_client.gui.conversation.export import ExportWizard +from securedrop_client.gui.conversation.export.whistleflow_dialog import WhistleflowDialog from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir @@ -111,7 +110,7 @@ class DeleteSourcesAction(QAction): def __init__( self, - parent: 'SourceListToolbar', + parent: "SourceListToolbar", controller: Controller, confirmation_dialog: Callable[[List[str]], QDialog], ) -> None: @@ -387,7 +386,7 @@ def _prepare_to_export(self) -> None: if self._destination == ExportDestination.WHISTLEFLOW: whistleflow_dialog = WhistleflowDialog( - self._export_device, + export_device, summary, [str(file_location) for file_location in file_locations], ) @@ -401,7 +400,6 @@ def _prepare_to_export(self) -> None: ) wizard.exec() - def _on_confirmation_dialog_accepted(self) -> None: self._prepare_to_export() diff --git a/client/securedrop_client/gui/auth/__init__.py b/client/securedrop_client/gui/auth/__init__.py index 4479d3621..c76ee01ba 100644 --- a/client/securedrop_client/gui/auth/__init__.py +++ b/client/securedrop_client/gui/auth/__init__.py @@ -17,5 +17,6 @@ along with this program. If not, see . """ -# Import classes here to make possible to import them from securedrop_client.gui.auth -from securedrop_client.gui.auth.dialog import LoginDialog # noqa: F401 +from securedrop_client.gui.auth.dialog import LoginDialog + +__all__ = ["LoginDialog"] diff --git a/client/securedrop_client/gui/conversation/export/whistleflow_dialog.py b/client/securedrop_client/gui/conversation/export/whistleflow_dialog.py index 41c22fbae..04db2d94c 100644 --- a/client/securedrop_client/gui/conversation/export/whistleflow_dialog.py +++ b/client/securedrop_client/gui/conversation/export/whistleflow_dialog.py @@ -2,6 +2,7 @@ A dialog that allows journalists to export conversations or transcripts to the Whistleflow View VM. This is a clone of FileDialog. """ + import datetime import logging from gettext import gettext as _ @@ -13,19 +14,18 @@ from securedrop_client.export import ExportError, ExportStatus from securedrop_client.gui.base import ModalDialog, SecureQLabel -from .device import Device +from ....export import Export logger = logging.getLogger(__name__) class WhistleflowDialog(ModalDialog): - DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") PASSPHRASE_LABEL_SPACING = 0.5 NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, summary: str, file_locations: List[str]) -> None: + def __init__(self, device: Export, summary: str, file_locations: List[str]) -> None: super().__init__() self.setStyleSheet(self.DIALOG_CSS) @@ -38,15 +38,17 @@ def __init__(self, device: Device, summary: str, file_locations: List[str]) -> N self.error_status: Optional[ExportStatus] = None # Connect device signals to slots - self._device.whistleflow_export_preflight_check_succeeded.connect( + self._device.whistleflow_preflight_check_succeeded.connect( self._on_export_preflight_check_succeeded ) - self._device.whistleflow_export_preflight_check_failed.connect( + self._device.whistleflow_preflight_check_failed.connect( self._on_export_preflight_check_failed ) self._device.export_completed.connect(self._on_export_succeeded) - self._device.whistleflow_export_failed.connect(self._on_export_failed) - self._device.whistleflow_export_succeeded.connect(self._on_export_succeeded) + + # # For now, connect both success and error signals to close the print dialog. + self._device.whistleflow_call_failure.connect(self._on_export_failed) + self._device.whistleflow_call_success.connect(self._on_export_succeeded) # Connect parent signals to slots self.continue_button.setEnabled(False) @@ -92,7 +94,7 @@ def _show_starting_instructions(self) -> None: def _send_to_whistleflow(self) -> None: timestamp = datetime.datetime.now().isoformat() - self._device.whistleflow_export_requested.emit( + self._device.send_files_to_whistleflow( "export-{}.tar".format(timestamp), self._file_locations ) diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 7c68caf04..63ed50bc2 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -73,8 +73,7 @@ Source, User, ) -from securedrop_client.export import ExportDestination -from securedrop_client.export import Export +from securedrop_client.export import Export, ExportDestination from securedrop_client.gui import conversation from securedrop_client.gui.actions import ( DeleteConversationAction, diff --git a/client/securedrop_client/logic.py b/client/securedrop_client/logic.py index 3de8ea726..286671439 100644 --- a/client/securedrop_client/logic.py +++ b/client/securedrop_client/logic.py @@ -31,7 +31,6 @@ import sqlalchemy.orm.exc from PyQt5.QtCore import QObject, QProcess, QThread, QTimer, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QCheckBox -from sdclientapi import AuthError, RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import sessionmaker from securedrop_client import db, sdk, state, storage diff --git a/client/tests/functional/test_delete_sources.py b/client/tests/functional/test_delete_sources.py index 407afeebb..3a71ffe82 100644 --- a/client/tests/functional/test_delete_sources.py +++ b/client/tests/functional/test_delete_sources.py @@ -1,6 +1,7 @@ """ Functional tests for deleting multiple sources in the SecureDrop client. """ + import pytest from flaky import flaky from PyQt5.QtWidgets import QCheckBox