Repository: mitmproxy/mitmproxy

Issue:
crash accessing .mitmproxy/command_history
#### Problem Description
mitmproxy crashes if `.mitmproxy/command_history` is inaccessible due to permissions

#### Steps to reproduce the behavior:
```$ mitmproxy --mode "reverse:https://qa1.onepeloton.com" -p 9999
Traceback (most recent call last):
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/master.py", line 86, in run_loop
    loop()
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/main_loop.py", line 287, in run
    self._run()
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/main_loop.py", line 385, in _run
    self.event_loop.run()
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/main_loop.py", line 1494, in run
    reraise(*exc_info)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/compat.py", line 58, in reraise
    raise value
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/raw_display.py", line 416, in <lambda>
    wrapper = lambda: self.parse_input(
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/raw_display.py", line 515, in parse_input
    callback(processed, processed_codes)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/main_loop.py", line 412, in _update
    self.process_input(keys)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/main_loop.py", line 513, in process_input
    k = self._topmost_widget.keypress(self.screen_size, k)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/tools/console/window.py", line 310, in keypress
    k = super().keypress(size, k)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/urwid/container.py", line 1123, in keypress
    return self.footer.keypress((maxcol,),key)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/tools/console/statusbar.py", line 201, in keypress
    return self.ab.keypress(*args, **kwargs)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/tools/console/statusbar.py", line 149, in keypress
    self.prompt_execute(text)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/tools/console/statusbar.py", line 169, in prompt_execute
    msg = p(txt)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/tools/console/statusbar.py", line 113, in execute_command
    self.master.commands.call("commands.history.add", txt)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/command.py", line 243, in call
    return self.commands[command_name].func(*args)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/command.py", line 296, in wrapper
    return function(*args, **kwargs)
  File "/usr/local/Cellar/mitmproxy/5.3.0/libexec/lib/python3.9/site-packages/mitmproxy/addons/command_history.py", line 51, in add_command
    with self.history_file.open("a") as f:
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1241, in open
    return io.open(self, mode, buffering, encoding, errors, newline,
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1109, in _opener
    return self._accessor.open(self, flags, mode)
PermissionError: [Errno 13] Permission denied: '/Users/***/.mitmproxy/command_history'

mitmproxy has crashed!
Please lodge a bug report at:
        https://github.com/mitmproxy/mitmproxy```

#### System Information
Mitmproxy: 5.3.0
Python:    3.9.0
OpenSSL:   OpenSSL 1.1.1h  22 Sep 2020
Platform:  macOS-10.15.7-x86_64-i386-64bit


Discussion:
What would be the expected behavior? mitmproxy controls `.mitmproxy` and its contents so it be should allowed to assume that it has access to everything in this folder. How did you end up with wrong permissions?
Expected behavior: don't crash, ideally display an error message instead but I'd say even just proceeding without writing command history is better than crashing. Chances are I ended up with this by running mitmproxy as root before - `.mitmproxy/command_history` was owned by root.
I don't consider this important enough to keep this issue open, but I'd be more than happy to merge a PR that fixes this. :)


Resolving patch:
diff --git a/mitmproxy/addons/command_history.py b/mitmproxy/addons/command_history.py
--- a/mitmproxy/addons/command_history.py
+++ b/mitmproxy/addons/command_history.py
@@ -39,7 +39,10 @@ def done(self):
         if ctx.options.command_history and len(self.history) >= self.VACUUM_SIZE:
             # vacuum history so that it doesn't grow indefinitely.
             history_str = "\n".join(self.history[-self.VACUUM_SIZE // 2:]) + "\n"
-            self.history_file.write_text(history_str)
+            try:
+                self.history_file.write_text(history_str)
+            except Exception as e:
+                ctx.log.alert(f"Failed writing to {self.history_file}: {e}")
 
     @command.command("commands.history.add")
     def add_command(self, command: str) -> None:
@@ -48,9 +51,11 @@ def add_command(self, command: str) -> None:
 
         self.history.append(command)
         if ctx.options.command_history:
-            with self.history_file.open("a") as f:
-                f.write(f"{command}\n")
-                f.close()
+            try:
+                with self.history_file.open("a") as f:
+                    f.write(f"{command}\n")
+            except Exception as e:
+                ctx.log.alert(f"Failed writing to {self.history_file}: {e}")
 
         self.set_filter('')
 
@@ -62,7 +67,10 @@ def get_history(self) -> typing.Sequence[str]:
     @command.command("commands.history.clear")
     def clear_history(self):
         if self.history_file.exists():
-            self.history_file.unlink()
+            try:
+                self.history_file.unlink()
+            except Exception as e:
+                ctx.log.alert(f"Failed deleting {self.history_file}: {e}")
         self.history = []
         self.set_filter('')
 


Test patch:
diff --git a/test/mitmproxy/addons/test_command_history.py b/test/mitmproxy/addons/test_command_history.py
--- a/test/mitmproxy/addons/test_command_history.py
+++ b/test/mitmproxy/addons/test_command_history.py
@@ -1,4 +1,8 @@
 import os
+from unittest.mock import patch
+from pathlib import Path
+
+import pytest
 
 from mitmproxy.addons import command_history
 from mitmproxy.test import taddons
@@ -22,16 +26,35 @@ def test_load_and_save(self, tmpdir):
         with open(history_file, "r") as f:
             assert f.read() == "cmd3\ncmd4\n"
 
+    @pytest.mark.asyncio
+    async def test_done_writing_failed(self):
+        ch = command_history.CommandHistory()
+        ch.VACUUM_SIZE = 1
+        with taddons.context(ch) as tctx:
+            ch.history.append('cmd1')
+            ch.history.append('cmd2')
+            ch.history.append('cmd3')
+            tctx.options.confdir = '/non/existent/path/foobar1234/'
+            ch.done()
+            assert await tctx.master.await_log(f"Failed writing to {ch.history_file}")
+
     def test_add_command(self):
-        history = command_history.CommandHistory()
+        ch = command_history.CommandHistory()
 
-        history.add_command('cmd1')
-        history.add_command('cmd2')
+        ch.add_command('cmd1')
+        ch.add_command('cmd2')
+        assert ch.history == ['cmd1', 'cmd2']
 
-        assert history.history == ['cmd1', 'cmd2']
+        ch.add_command('')
+        assert ch.history == ['cmd1', 'cmd2']
 
-        history.add_command('')
-        assert history.history == ['cmd1', 'cmd2']
+    @pytest.mark.asyncio
+    async def test_add_command_failed(self):
+        ch = command_history.CommandHistory()
+        with taddons.context(ch) as tctx:
+            tctx.options.confdir = '/non/existent/path/foobar1234/'
+            ch.add_command('cmd1')
+            assert await tctx.master.await_log(f"Failed writing to {ch.history_file}")
 
     def test_get_next_and_prev(self, tmpdir):
         ch = command_history.CommandHistory()
@@ -133,6 +156,20 @@ def test_clear(self, tmpdir):
 
             ch.clear_history()
 
+    @pytest.mark.asyncio
+    async def test_clear_failed(self, monkeypatch):
+        ch = command_history.CommandHistory()
+
+        with taddons.context(ch) as tctx:
+            tctx.options.confdir = '/non/existent/path/foobar1234/'
+
+            with patch.object(Path, 'exists') as mock_exists:
+                mock_exists.return_value = True
+                with patch.object(Path, 'unlink') as mock_unlink:
+                    mock_unlink.side_effect = IOError()
+                    ch.clear_history()
+            assert await tctx.master.await_log(f"Failed deleting {ch.history_file}")
+
     def test_filter(self, tmpdir):
         ch = command_history.CommandHistory()