Skip to content

Commit

Permalink
Fix whistleflow export following merge of 0.10.2
Browse files Browse the repository at this point in the history
  • Loading branch information
philmcmahon committed Jul 19, 2024
1 parent a43c9bf commit ef8cc93
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 24 deletions.
124 changes: 119 additions & 5 deletions client/securedrop_client/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
10 changes: 4 additions & 6 deletions client/securedrop_client/gui/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -111,7 +110,7 @@ class DeleteSourcesAction(QAction):

def __init__(
self,
parent: 'SourceListToolbar',
parent: "SourceListToolbar",
controller: Controller,
confirmation_dialog: Callable[[List[str]], QDialog],
) -> None:
Expand Down Expand Up @@ -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],
)
Expand All @@ -401,7 +400,6 @@ def _prepare_to_export(self) -> None:
)
wizard.exec()


def _on_confirmation_dialog_accepted(self) -> None:
self._prepare_to_export()

Expand Down
5 changes: 3 additions & 2 deletions client/securedrop_client/gui/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

# 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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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
)

Expand Down
3 changes: 1 addition & 2 deletions client/securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion client/securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions client/tests/functional/test_delete_sources.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit ef8cc93

Please sign in to comment.