Skip to content

Commit

Permalink
Export and print use single method signature across components. Devic…
Browse files Browse the repository at this point in the history
…e does not depend on filepaths.
  • Loading branch information
rocodes committed Jan 26, 2024
1 parent 74f03ad commit 46c085e
Show file tree
Hide file tree
Showing 17 changed files with 153 additions and 282 deletions.
12 changes: 7 additions & 5 deletions client/securedrop_client/gui/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@ def _on_triggered(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with open(file_path, "r") as f:
export = ExportDevice([file_path])
dialog = PrintConversationTranscriptDialog(export, TRANSCRIPT_FILENAME, str(file_path))
export = ExportDevice()
dialog = PrintConversationTranscriptDialog(
export, TRANSCRIPT_FILENAME, [str(file_path)]
)
dialog.exec()


Expand Down Expand Up @@ -236,9 +238,9 @@ def _on_triggered(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with open(file_path, "r") as f:
export_device = ExportDevice([file_path])
export_device = ExportDevice()
dialog = ExportConversationTranscriptDialog(
export_device, TRANSCRIPT_FILENAME, str(file_path)
export_device, TRANSCRIPT_FILENAME, [str(file_path)]
)
dialog.exec()

Expand Down Expand Up @@ -325,7 +327,7 @@ def _prepare_to_export(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with ExitStack() as stack:
export_device = ExportDevice(file_locations)
export_device = ExportDevice()
files = [
stack.enter_context(open(file_location, "r")) for file_location in file_locations
]
Expand Down
175 changes: 72 additions & 103 deletions client/securedrop_client/gui/conversation/export/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from io import BytesIO
from shlex import quote
from tempfile import TemporaryDirectory
from typing import List
from typing import List, Optional

from PyQt5.QtCore import QObject, pyqtSignal

Expand Down Expand Up @@ -48,28 +48,30 @@ class Device(QObject):
print_preflight_check_succeeded = pyqtSignal(object)
print_succeeded = pyqtSignal(object)

# Used for both print and export
export_completed = pyqtSignal(object)

# Emit ExportError(status=ExportStatus)
export_preflight_check_failed = pyqtSignal(object)
export_failed = pyqtSignal(object)

print_preflight_check_failed = pyqtSignal(object)
print_failed = pyqtSignal(object)

def __init__(self, filepaths: [str]) -> None:
super().__init__()

self._filepaths_list = filepaths

def run_printer_preflight_checks(self) -> None:
"""
Make sure the Export VM is started.
"""
logger.info("Running printer preflight check")
logger.info("Beginning printer preflight check")
try:
status = self._build_archive_and_export(
metadata=self._PRINTER_PREFLIGHT_METADATA, filename=self._PRINTER_PREFLIGHT_FN
)
self.print_preflight_check_succeeded.emit(status)
with TemporaryDirectory() as tmp_dir:
archive_path = self._create_archive(
archive_dir=tmp_dir,
archive_fn=self._PRINTER_PREFLIGHT_FN,
metadata=self._PRINTER_PREFLIGHT_METADATA,
)
status = self._run_qrexec_export(archive_path)
self.print_preflight_check_succeeded.emit(status)
except ExportError as e:
logger.error("Print preflight failed")
logger.debug(f"Print preflight failed: {e}")
Expand All @@ -81,50 +83,77 @@ def run_export_preflight_checks(self) -> None:
"""
try:
logger.debug("Beginning export preflight check")
status = self._build_archive_and_export(
metadata=self._USB_TEST_METADATA, filename=self._USB_TEST_FN
)
self.export_preflight_check_succeeded.emit(status)

with TemporaryDirectory() as tmp_dir:
archive_path = self._create_archive(
archive_dir=tmp_dir,
archive_fn=self._USB_TEST_FN,
metadata=self._USB_TEST_METADATA,
)
status = self._run_qrexec_export(archive_path)
self.export_preflight_check_succeeded.emit(status)

except ExportError as e:
logger.error("Export preflight failed")
self.export_preflight_check_failed.emit(e)

def export_transcript(self, file_location: str, passphrase: str) -> None:
def export(self, filepaths: List[str], passphrase: Optional[str]) -> None:
"""
Send the transcript specified by file_location to the Export VM.
Bundle filepaths into a tarball and send to encrypted USB via qrexec,
optionally supplying a passphrase to unlock encrypted drives.
"""
logger.debug("Export transcript")
self._send_file_to_usb_device([file_location], passphrase)
try:
logger.debug(f"Begin exporting {len(filepaths)} item(s)")

def export_files(self, file_locations: List[str], passphrase: str) -> None:
"""
Send the files specified by file_locations to the Export VM.
"""
logger.debug(f"Export {len(file_locations)} files")
self._send_file_to_usb_device(file_locations, passphrase)
# Edit metadata template to include passphrase
metadata = self._DISK_METADATA.copy()
if passphrase:
metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase

def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None:
"""
Send the file specified by file_uuid to the Export VM with the user-provided passphrase for
unlocking the attached transfer device. If the file is missing, update the db so that
is_downloaded is set to False.
"""
with TemporaryDirectory() as tmp_dir:
archive_path = self._create_archive(
archive_dir=tmp_dir,
archive_fn=self._DISK_FN,
metadata=metadata,
filepaths=filepaths,
)
status = self._run_qrexec_export(archive_path)

self._send_file_to_usb_device(self._filepaths_list, passphrase)
self.export_succeeded.emit(status)
logger.debug(f"Status {status}")

def print_transcript(self, file_location: str) -> None:
"""
Send the transcript specified by file_location to the Export VM.
"""
self._print([file_location])
except ExportError as e:
logger.error("Export failed")
logger.debug(f"Export failed: {e}")
self.export_failed.emit(e)

self.export_completed.emit(filepaths)

def print_file(self, file_uuid: str) -> None:
def print(self, filepaths: List[str]) -> None:
"""
Send the file specified by file_uuid to the Export VM. If the file is missing, update the db
so that is_downloaded is set to False.
Bundle files at self._filepaths_list into tarball and send for
printing via qrexec.
"""
try:
logger.debug("Beginning print")

with TemporaryDirectory() as tmp_dir:
archive_path = self._create_archive(
archive_dir=tmp_dir,
archive_fn=self._PRINT_FN,
metadata=self._PRINT_METADATA,
filepaths=filepaths,
)
status = self._run_qrexec_export(archive_path)
self.print_succeeded.emit(status)
logger.debug(f"Status {status}")

self._print(self._filepaths_list)
except ExportError as e:
logger.error("Export failed")
logger.debug(f"Export failed: {e}")
self.print_failed.emit(e)

self.export_completed.emit(filepaths)

def _run_qrexec_export(self, archive_path: str) -> ExportStatus:
"""
Expand Down Expand Up @@ -174,7 +203,7 @@ def _run_qrexec_export(self, archive_path: str) -> ExportStatus:
raise ExportError(ExportStatus.CALLED_PROCESS_ERROR)

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] = []
) -> str:
"""
Create the archive to be sent to the Export VM.
Expand Down Expand Up @@ -210,7 +239,7 @@ def _create_archive(
self._add_file_to_archive(
archive, filepath, prevent_name_collisions=is_one_of_multiple_files
)
if missing_count == len(filepaths):
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
logger.error("Files were moved or missing")
Expand Down Expand Up @@ -258,63 +287,3 @@ def _add_file_to_archive(
arcname = os.path.join("export_data", parent_name, filename)

archive.add(filepath, arcname=arcname, recursive=False)

def _build_archive_and_export(
self, metadata: dict, filename: str, filepaths: List[str] = []
) -> ExportStatus:
"""
Build archive, run qrexec command and return resulting ExportStatus.
ExportError may be raised during underlying _run_qrexec_export call,
and is handled by the calling method.
"""
with TemporaryDirectory() as tmp_dir:
archive_path = self._create_archive(
archive_dir=tmp_dir, archive_fn=filename, metadata=metadata, filepaths=filepaths
)
return self._run_qrexec_export(archive_path)

def _send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None:
"""
Export the file to the luks-encrypted usb disk drive attached to the Export VM.
Args:
filepath: The path of file to export.
passphrase: The passphrase to unlock the luks-encrypted usb disk drive.
"""
try:
logger.debug("beginning export")
# Edit metadata template to include passphrase
metadata = self._DISK_METADATA.copy()
metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase
status = self._build_archive_and_export(
metadata=metadata, filename=self._DISK_FN, filepaths=filepaths
)

self.export_succeeded.emit(status)
logger.debug(f"Status {status}")
except ExportError as e:
logger.error("Export failed")
logger.debug(f"Export failed: {e}")
self.export_failed.emit(e)

def _print(self, filepaths: List[str]) -> None:
"""
Print the file to the printer attached to the Export VM.
Args:
filepath: The path of file to export.
"""
try:
logger.debug("beginning print")
status = self._build_archive_and_export(
metadata=self._PRINT_METADATA, filename=self._PRINT_FN, filepaths=filepaths
)
self.print_succeeded.emit(status)
logger.debug(f"Status {status}")
except ExportError as e:
logger.error("Export failed")
logger.debug(f"Export failed: {e}")
self.print_failed.emit(e)

self.export_succeeded.emit(filepaths)
8 changes: 4 additions & 4 deletions client/securedrop_client/gui/conversation/export/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ class Dialog(FileDialog):
- Overrides the two slots that handles the export action to call said method.
"""

def __init__(self, device: Device, summary: str, file_locations: List[str]) -> None:
super().__init__(device, "", summary)
def __init__(self, device: Device, summary: str, filepaths: List[str]) -> None:
super().__init__(device, summary, filepaths)

self.file_locations = file_locations
self.filepaths = filepaths

@pyqtSlot(bool)
def _export_files(self, checked: bool = False) -> None:
self.start_animate_activestate()
self.cancel_button.setEnabled(False)
self.passphrase_field.setDisabled(True)
self._device.export_files(self.file_locations, self.passphrase_field.text())
self._device.export(self.filepaths, self.passphrase_field.text())

@pyqtSlot()
def _show_passphrase_request_message(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
A dialog that allows journalists to export sensitive files to a USB drive.
"""
from gettext import gettext as _
from typing import Optional
from typing import List, Optional

from pkg_resources import resource_string
from PyQt5.QtCore import QSize, Qt, pyqtSlot
Expand All @@ -23,12 +23,12 @@ class FileDialog(ModalDialog):
NO_MARGIN = 0
FILENAME_WIDTH_PX = 260

def __init__(self, device: Device, file_uuid: str, file_name: str) -> None:
def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None:
super().__init__()
self.setStyleSheet(self.DIALOG_CSS)

self._device = device
self.file_uuid = file_uuid
self.filepaths = filepaths
self.file_name = SecureQLabel(
file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX
).text()
Expand Down Expand Up @@ -215,7 +215,7 @@ def _export_file(self, checked: bool = False) -> None:
# TODO: If the drive is already unlocked, the passphrase field will be empty.
# This is ok, but could violate expectations. The password should be passed
# via qrexec in future, to avoid writing it to even a temporary file at all.
self._device.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text())
self._device.export(self.filepaths, self.passphrase_field.text())

@pyqtSlot(object)
def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from gettext import gettext as _
from typing import Optional
from typing import List, Optional

from PyQt5.QtCore import QSize, pyqtSlot

Expand All @@ -12,11 +12,11 @@
class PrintDialog(ModalDialog):
FILENAME_WIDTH_PX = 260

def __init__(self, device: Device, file_uuid: str, file_name: str) -> None:
def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None:
super().__init__()

self._device = device
self.file_uuid = file_uuid
self.filepaths = filepaths
self.file_name = SecureQLabel(
file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX
).text()
Expand Down Expand Up @@ -94,7 +94,7 @@ def _run_preflight(self) -> None:

@pyqtSlot()
def _print_file(self) -> None:
self._device.print_file(self.file_uuid)
self._device.print(self.filepaths)
self.close()

@pyqtSlot()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import List

from PyQt5.QtCore import QSize, pyqtSlot

from securedrop_client.gui.conversation.export import PrintDialog
Expand All @@ -13,13 +15,15 @@ class PrintTranscriptDialog(PrintDialog):
- Overrides the slot that handles the printing action to call said method.
"""

def __init__(self, device: Device, file_name: str, transcript_location: str) -> None:
super().__init__(device, "", file_name)
def __init__(self, device: Device, file_name: str, filepath: List[str]) -> None:
super().__init__(device, file_name, filepath)

self.transcript_location = transcript_location
# List might seem like an odd choice for this, but this is on the
# way to standardizing one export/print dialog that can send multiple items
self.transcript_location = filepath

def _print_transcript(self) -> None:
self._device.print_transcript(self.transcript_location)
self._device.print(self.transcript_location)
self.close()

@pyqtSlot()
Expand Down
Loading

0 comments on commit 46c085e

Please sign in to comment.