Repository: qutebrowser/qutebrowser

Issue:
Shorten long download filenames based on title
When doing `:download --mhtml` on https://old.reddit.com/r/Stoicism/comments/dov3zh/anger_is_essential_to_a_healthy_personality_and/ it fails and gets cancelled by QtWebEngine:

```
10:36:56 DEBUG    downloads  downloads:_after_create_parent_question:739 Setting filename to /tmp/qutebrowser-basedir-apkfu5z3/download/_Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question.mhtml
10:36:56 DEBUG    downloads  webenginedownloads:_on_state_changed:61 State for <qutebrowser.browser.webengine.webenginedownloads.DownloadItem basename='_Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question.mhtml'> changed to DownloadInProgress
[362163:362250:1102/103656.654366:ERROR:mhtml_generation_manager.cc(574)] Failed to create file to save MHTML at: /tmp/qutebrowser-basedir-apkfu5z3/download/_Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question.mhtml
[362163:362163:1102/103656.676847:ERROR:mhtml_generation_manager.cc(588)] Failed to create file
10:36:56 DEBUG    downloads  webenginedownloads:_on_state_changed:61 State for <qutebrowser.browser.webengine.webenginedownloads.DownloadItem basename='_Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question.mhtml'> changed to DownloadCancelled
10:36:56 DEBUG    downloads  downloads:_remove_item:924 Removed download 1: _Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question.mhtml [   0.00B/s|0.00B]
```

A similar thing happens with `:download`, though at least the error is visible:

```
10:53:02 DEBUG    downloads  downloads:_after_create_parent_question:739 Setting filename to /tmp/qutebrowser-basedir-apkfu5z3/download/"Anger is essential to a healthy personality, and someone who never feels anger is not always going to do the right thing and will therefore not achieve happiness. Yet too much anger is also a shortcoming or defect – a vice. It is always a question of the right amount at the right time." : Stoicism.html
10:53:02 DEBUG    message    message:_log_stack:42 Stack for error message:
  [...]
  File "/home/florian/proj/qutebrowser/git/qutebrowser/utils/usertypes.py", line 375, in done
    self.answered.emit(self.answer)
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/downloads.py", line 792, in set_target
    target.filename, force_overwrite=target.force_overwrite)
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/downloads.py", line 716, in _set_filename
    remember_directory)
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/downloads.py", line 755, in _after_create_parent_question
    self._after_set_filename()
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/qtnetworkdownloads.py", line 202, in _after_set_filename
    self._create_fileobj()
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/qtnetworkdownloads.py", line 114, in _create_fileobj
    self._die(e.strerror)
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/downloads.py", line 535, in _die
    self.error.emit(msg)
  File "/home/florian/proj/qutebrowser/git/qutebrowser/browser/downloads.py", line 904, in _on_error
    message.error("Download error: {}".format(msg))
  File "/home/florian/proj/qutebrowser/git/qutebrowser/utils/message.py", line 54, in error
    stack = ''.join(traceback.format_stack())
10:53:02 ERROR    message    message:error:59 Download error: File name too long
```



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
@@ -230,9 +230,16 @@ def suggested_fn_from_title(url_path, title=None):
 
     suggested_fn = None  # type: typing.Optional[str]
     if ext.lower() in ext_whitelist and title:
-        suggested_fn = utils.sanitize_filename(title)
+        suggested_fn = utils.sanitize_filename(
+            title,
+            max_bytes=(255 - len("(123).download"))
+        )
         if not suggested_fn.lower().endswith((".html", ".htm")):
             suggested_fn += ".html"
+            suggested_fn = utils.sanitize_filename(
+                suggested_fn,
+                max_bytes=(255 - len("(123).download"))
+            )
 
     return suggested_fn
 
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -518,7 +518,8 @@ def force_encoding(text: str, encoding: str) -> str:
 
 
 def sanitize_filename(name: str,
-                      replacement: typing.Optional[str] = '_') -> str:
+                      replacement: typing.Optional[str] = '_',
+                      max_bytes: typing.Optional[int] = None) -> str:
     """Replace invalid filename characters.
 
     Note: This should be used for the basename, as it also removes the path
@@ -527,6 +528,7 @@ def sanitize_filename(name: str,
     Args:
         name: The filename.
         replacement: The replacement character (or None).
+        max_bytes: The biggest length in bytes allowed (or None, which is ∞).
     """
     if replacement is None:
         replacement = ''
@@ -548,6 +550,34 @@ def sanitize_filename(name: str,
 
     for bad_char in bad_chars:
         name = name.replace(bad_char, replacement)
+
+    if max_bytes:
+        # Truncate the filename if it's too long.
+        root, ext = os.path.splitext(name)
+        root = root[:max_bytes - len(ext)]
+        excess = len(os.fsencode(root + ext)) - max_bytes
+
+        while excess > 0 and root:
+            # Max 4 bytes per character is assumed.
+            # Integer division floors to -∞, not to 0.
+            root = root[:(-excess // 4)]
+            excess = len(os.fsencode(root + ext)) - max_bytes
+
+        if not root:
+            # Trimming the root is not enough. We must trim the extension.
+            # We leave one character in the root, so that the filename
+            # doesn't start with a dot, which makes the file hidden.
+            root = name[0]
+            excess = len(os.fsencode(root + ext)) - max_bytes
+            while excess > 0 and ext:
+                ext = ext[:(-excess // 4)]
+                excess = len(os.fsencode(root + ext)) - max_bytes
+
+            if not ext:
+                raise ValueError("max_bytes is too small")
+
+        name = root + ext
+
     return name
 
 


Test patch:
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -644,6 +644,13 @@ def test_sanitize_filename_empty_replacement():
     assert utils.sanitize_filename(name, replacement=None) == 'Bad File'
 
 
+@hypothesis.given(filename=strategies.text(min_size=100),
+                  max_bytes=strategies.integers(min_value=50))
+def test_sanitize_filename_invariants(filename, max_bytes):
+    sanitized = utils.sanitize_filename(filename, max_bytes=max_bytes)
+    assert len(os.fsencode(sanitized)) <= max_bytes
+
+
 class TestGetSetClipboard:
 
     @pytest.fixture(autouse=True)