Repository: qutebrowser/qutebrowser

Issue:
Downloads get overwritten when starting a second download with the same name
http://paste.the-compiler.org/view/037805f7

When I download a file and then start downloading another file with exactly the same filename before the first download is finished, then the first file gets silently overwritten.  This has bitten me multiple times now.

Currently it seems that qutebrowser first creates a file with the extension `.download`, writes to it and then renames it to the actual filename.  Maybe the actual file should be touched right at the beginning of the download process, so that the next download attempt triggers the usual warning.

Firefox does sth. like that.


Discussion:

Resolving patch:
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -426,6 +426,7 @@ class AbstractDownloadItem(QObject):
         raw_headers: The headers sent by the server.
         _filename: The filename of the download.
         _dead: Whether the Download has _die()'d.
+        _manager: The DownloadManager which started this download.
 
     Signals:
         data_changed: The downloads metadata changed.
@@ -446,9 +447,11 @@ class AbstractDownloadItem(QObject):
     cancelled = pyqtSignal()
     remove_requested = pyqtSignal()
     pdfjs_requested = pyqtSignal(str, QUrl)
+    _manager = None  # type: AbstractDownloadManager
 
-    def __init__(self, parent=None):
+    def __init__(self, manager, parent=None):
         super().__init__(parent)
+        self._manager = manager
         self.done = False
         self.stats = DownloadItemStats(self)
         self.index = 0
@@ -464,6 +467,9 @@ def __init__(self, parent=None):
         self._filename = None  # type: typing.Optional[str]
         self._dead = False
 
+    def get_filename(self):
+        return self._filename
+
     def __repr__(self):
         return utils.get_repr(self, basename=self.basename)
 
@@ -647,7 +653,7 @@ def _after_set_filename(self):
         """Finish initialization based on self._filename."""
         raise NotImplementedError
 
-    def _ask_confirm_question(self, title, msg):
+    def _ask_confirm_question(self, title, msg, custom_yes_action=None):
         """Ask a confirmation question for the download."""
         raise NotImplementedError
 
@@ -742,7 +748,13 @@ def _after_create_parent_question(self,
             last_used_directory = os.path.dirname(self._filename)
 
         log.downloads.debug("Setting filename to {}".format(self._filename))
-        if force_overwrite:
+        if self._get_conflicting_download():
+            txt = ("<b>{}</b> is already downloading. Cancel and "
+                   "re-download?".format(html.escape(self._filename)))
+            self._ask_confirm_question(
+                "Cancel other download(s)?", txt,
+                custom_yes_action=self._cancel_conflicting_download)
+        elif force_overwrite:
             self._after_set_filename()
         elif os.path.isfile(self._filename):
             # The file already exists, so ask the user if it should be
@@ -759,6 +771,24 @@ def _after_create_parent_question(self,
         else:
             self._after_set_filename()
 
+    def _get_conflicting_download(self):
+        """Return another potential active download with the same name."""
+        for download in self._manager.downloads:
+            if (
+                    download is not self and
+                    download.get_filename() == self._filename and
+                    not download.done
+            ):
+                return download
+        return None
+
+    def _cancel_conflicting_download(self):
+        """Cancel any conflicting download and call _after_set_filename."""
+        conflicting_download = self._get_conflicting_download()
+        if conflicting_download:
+            conflicting_download.cancel(remove_data=False)
+        self._after_set_filename()
+
     def _open_if_successful(self, cmdline):
         """Open the downloaded file, but only if it was successful.
 
diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py
--- a/qutebrowser/browser/qtnetworkdownloads.py
+++ b/qutebrowser/browser/qtnetworkdownloads.py
@@ -70,7 +70,6 @@ class DownloadItem(downloads.AbstractDownloadItem):
                  target file.
         _read_timer: A Timer which reads the QNetworkReply into self._buffer
                      periodically.
-        _manager: The DownloadManager which started this download
         _reply: The QNetworkReply associated with this download.
         _autoclose: Whether to close the associated file when the download is
                     done.
@@ -90,12 +89,11 @@ def __init__(self, reply, manager):
         Args:
             reply: The QNetworkReply to download.
         """
-        super().__init__(parent=manager)
+        super().__init__(manager=manager, parent=manager)
         self.fileobj = None  # type: typing.Optional[typing.IO[bytes]]
         self.raw_headers = {}  # type: typing.Dict[bytes, bytes]
 
         self._autoclose = True
-        self._manager = manager
         self._retry_info = None
         self._reply = None
         self._buffer = io.BytesIO()
@@ -206,11 +204,11 @@ def _ensure_can_set_filename(self, filename):
     def _after_set_filename(self):
         self._create_fileobj()
 
-    def _ask_confirm_question(self, title, msg):
+    def _ask_confirm_question(self, title, msg, custom_yes_action=None):
+        yes_action = custom_yes_action or self._after_set_filename
         no_action = functools.partial(self.cancel, remove_data=False)
         url = 'file://{}'.format(self._filename)
-        message.confirm_async(title=title, text=msg,
-                              yes_action=self._after_set_filename,
+        message.confirm_async(title=title, text=msg, yes_action=yes_action,
                               no_action=no_action, cancel_action=no_action,
                               abort_on=[self.cancelled, self.error], url=url)
 
diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py
--- a/qutebrowser/browser/webengine/webenginedownloads.py
+++ b/qutebrowser/browser/webengine/webenginedownloads.py
@@ -28,7 +28,7 @@
 from PyQt5.QtWebEngineWidgets import QWebEngineDownloadItem
 
 from qutebrowser.browser import downloads, pdfjs
-from qutebrowser.utils import debug, usertypes, message, log, qtutils
+from qutebrowser.utils import debug, usertypes, message, log, qtutils, objreg
 
 
 class DownloadItem(downloads.AbstractDownloadItem):
@@ -39,8 +39,8 @@ class DownloadItem(downloads.AbstractDownloadItem):
         _qt_item: The wrapped item.
     """
 
-    def __init__(self, qt_item: QWebEngineDownloadItem, parent=None):
-        super().__init__(parent)
+    def __init__(self, qt_item: QWebEngineDownloadItem, manager, parent=None):
+        super().__init__(manager=manager, parent=manager)
         self._qt_item = qt_item
         qt_item.downloadProgress.connect(  # type: ignore
             self.stats.on_download_progress)
@@ -137,14 +137,15 @@ def _ensure_can_set_filename(self, filename):
                              "state {} (not in requested state)!".format(
                                  filename, self, state_name))
 
-    def _ask_confirm_question(self, title, msg):
+    def _ask_confirm_question(self, title, msg, custom_yes_action=None):
+        yes_action = custom_yes_action or self._after_set_filename
         no_action = functools.partial(self.cancel, remove_data=False)
         question = usertypes.Question()
         question.title = title
         question.text = msg
         question.url = 'file://{}'.format(self._filename)
         question.mode = usertypes.PromptMode.yesno
-        question.answered_yes.connect(self._after_set_filename)
+        question.answered_yes.connect(yes_action)
         question.answered_no.connect(no_action)
         question.cancelled.connect(no_action)
         self.cancelled.connect(question.abort)
@@ -174,6 +175,30 @@ def _after_set_filename(self):
         self._qt_item.setPath(self._filename)
         self._qt_item.accept()
 
+    def _get_conflicting_download(self):
+        """Return another potential active download with the same name.
+
+        webenginedownloads.DownloadItem needs to look for downloads both in its
+        manager and in qtnetwork-download-manager as both are used
+        simultaneously.
+
+        This method can be safely removed once #2328 is fixed.
+        """
+        conflicting_download = super()._get_conflicting_download()
+        if not conflicting_download:
+            qtnetwork_download_manager = objreg.get(
+                'qtnetwork-download-manager')
+            for download in qtnetwork_download_manager.downloads:
+                if (
+                        download is not self and
+                        download.get_filename() == self._filename and
+                        not download.done
+                ):
+                    return download
+            return None
+        else:
+            return conflicting_download
+
 
 def _get_suggested_filename(path):
     """Convert a path we got from chromium to a suggested filename.
@@ -233,7 +258,7 @@ def handle_download(self, qt_item):
         suggested_filename = _get_suggested_filename(qt_item.path())
         use_pdfjs = pdfjs.should_use_pdfjs(qt_item.mimeType(), qt_item.url())
 
-        download = DownloadItem(qt_item)
+        download = DownloadItem(qt_item, manager=self)
         self._init_item(download, auto_remove=use_pdfjs,
                         suggested_filename=suggested_filename)
 


Test patch:
diff --git a/tests/unit/browser/webkit/test_downloads.py b/tests/unit/browser/webkit/test_downloads.py
--- a/tests/unit/browser/webkit/test_downloads.py
+++ b/tests/unit/browser/webkit/test_downloads.py
@@ -107,7 +107,7 @@ def test_sanitized_filenames(raw, expected,
                              config_stub, download_tmpdir, monkeypatch):
     manager = downloads.AbstractDownloadManager()
     target = downloads.FileDownloadTarget(str(download_tmpdir))
-    item = downloads.AbstractDownloadItem()
+    item = downloads.AbstractDownloadItem(manager=manager)
 
     # Don't try to start a timer outside of a QThread
     manager._update_timer.isActive = lambda: True
@@ -116,6 +116,77 @@ def test_sanitized_filenames(raw, expected,
     item._ensure_can_set_filename = lambda *args: True
     item._after_set_filename = lambda *args: True
 
+    # Don't try to get current window
+    monkeypatch.setattr(item, '_get_conflicting_download', list)
+
     manager._init_item(item, True, raw)
     item.set_target(target)
     assert item._filename.endswith(expected)
+
+
+class TestConflictingDownloads:
+
+    def test_no_downloads(self, qapp, qtmodeltester, config_stub,
+                          cookiejar_and_cache, fake_args, monkeypatch):
+        manager = qtnetworkdownloads.DownloadManager()
+        my_item = downloads.AbstractDownloadItem(manager=manager)
+        my_item._filename = 'download.txt'
+        assert my_item._get_conflicting_download() is None
+
+    def test_different_name(self, qapp, qtmodeltester, config_stub,
+                            cookiejar_and_cache, fake_args, monkeypatch):
+        manager = qtnetworkdownloads.DownloadManager()
+        my_item = downloads.AbstractDownloadItem(manager=manager)
+        my_item._filename = 'download.txt'
+        item2 = downloads.AbstractDownloadItem(manager=manager)
+        item2._filename = 'download2.txt'
+        item2.done = False
+        manager.downloads.append(my_item)
+        manager.downloads.append(item2)
+        assert my_item._get_conflicting_download() is None
+
+    def test_finished_download(self, qapp, qtmodeltester, config_stub,
+                               cookiejar_and_cache, fake_args, monkeypatch):
+        manager = qtnetworkdownloads.DownloadManager()
+        my_item = downloads.AbstractDownloadItem(manager=manager)
+        my_item._filename = 'download.txt'
+        item2 = downloads.AbstractDownloadItem(manager=manager)
+        item2._filename = 'download.txt'
+        item2.done = True
+        manager.downloads.append(my_item)
+        manager.downloads.append(item2)
+        assert my_item._get_conflicting_download() is None
+
+    def test_conflicting_downloads(self, qapp, qtmodeltester, config_stub,
+                                   cookiejar_and_cache, fake_args,
+                                   monkeypatch):
+        manager = qtnetworkdownloads.DownloadManager()
+        my_item = downloads.AbstractDownloadItem(manager=manager)
+        my_item._filename = 'download.txt'
+        item2 = downloads.AbstractDownloadItem(manager=manager)
+        item2._filename = 'download.txt'
+        item2.done = False
+        manager.downloads.append(my_item)
+        manager.downloads.append(item2)
+        assert my_item._get_conflicting_download() is item2
+
+    def test_cancel_conflicting_downloads(self, qapp, qtmodeltester,
+                                          config_stub, cookiejar_and_cache,
+                                          fake_args, monkeypatch):
+        manager = qtnetworkdownloads.DownloadManager()
+        my_item = downloads.AbstractDownloadItem(manager=manager)
+        my_item._filename = 'download.txt'
+        item2 = downloads.AbstractDownloadItem(manager=manager)
+        item2._filename = 'download.txt'
+        item2.done = False
+        manager.downloads.append(my_item)
+        manager.downloads.append(item2)
+
+        def patched_cancel(remove_data=True):
+            assert not remove_data
+            item2.done = True
+
+        monkeypatch.setattr(item2, 'cancel', patched_cancel)
+        monkeypatch.setattr(my_item, '_after_set_filename', lambda: None)
+        my_item._cancel_conflicting_download()
+        assert item2.done