diff --git a/mitmproxy/addons/view.py b/mitmproxy/addons/view.py new file mode 100644 index 000000000..8c0567a50 --- /dev/null +++ b/mitmproxy/addons/view.py @@ -0,0 +1,269 @@ +""" +The View: + +- Keeps track of a store of flows +- Maintains a filtered, ordered view onto that list of flows +- Exposes a number of signals so the view can be monitored +- Tracks focus within the view +- Exposes a settings store for flows that automatically expires if the flow is + removed from the store. +""" +import collections +import typing +import datetime + +import blinker +import sortedcontainers + +import mitmproxy.flow +from mitmproxy import flowfilter + + +def key_request_start(f: mitmproxy.flow.Flow) -> datetime.datetime: + return f.request.timestamp_start or 0 + + +def key_request_method(f: mitmproxy.flow.Flow) -> str: + return f.request.method + + +matchall = flowfilter.parse(".") + + +class View(collections.Sequence): + def __init__(self): + super().__init__() + self._store = {} + self.filter = matchall + self.order_key = key_request_start + self.order_reversed = False + self._view = sortedcontainers.SortedListWithKey(key = self.order_key) + + # These signals broadcast events that affect the view. That is, an + # update to a flow in the store but not in the view does not trigger a + # signal. All signals are called after the view has been updated. + self.sig_update = blinker.Signal() + self.sig_add = blinker.Signal() + self.sig_remove = blinker.Signal() + # Signals that the view should be refreshed completely + self.sig_refresh = blinker.Signal() + + self.focus = Focus(self) + self.settings = Settings(self) + + def _rev(self, idx: int) -> int: + """ + Reverses an index, if needed + """ + if self.order_reversed: + if idx < 0: + idx = -idx - 1 + else: + idx = len(self._view) - idx - 1 + if idx < 0: + raise IndexError + return idx + + def __len__(self): + return len(self._view) + + def __getitem__(self, offset) -> mitmproxy.flow.Flow: + return self._view[self._rev(offset)] + + # Reflect some methods to the efficient underlying implementation + + def bisect(self, f: mitmproxy.flow.Flow) -> int: + v = self._view.bisect(f) + # Bisect returns an item to the RIGHT of the existing entries. + if v == 0: + return v + return self._rev(v - 1) + 1 + + def index(self, f: mitmproxy.flow.Flow) -> int: + return self._rev(self._view.index(f)) + + # API + + def toggle_reversed(self): + self.order_reversed = not self.order_reversed + self.sig_refresh.send(self) + + def set_order(self, order_key: typing.Callable): + """ + Sets the current view order. + """ + self.order_key = order_key + newview = sortedcontainers.SortedListWithKey(key=order_key) + newview.update(self._view) + self._view = newview + + def set_filter(self, flt: typing.Optional[flowfilter.TFilter]): + """ + Sets the current view filter. + """ + self.filter = flt or matchall + self._view.clear() + for i in self._store.values(): + if self.filter(i): + self._view.add(i) + self.sig_refresh.send(self) + + def clear(self): + """ + Clears both the state and view. + """ + self._state.clear() + self._view.clear() + self.sig_refresh.send(self) + + def add(self, f: mitmproxy.flow.Flow): + """ + Adds a flow to the state. If the flow already exists, it is + ignored. + """ + if f.id not in self._store: + self._store[f.id] = f + if self.filter(f): + self._view.add(f) + self.sig_add.send(self, flow=f) + + def remove(self, f: mitmproxy.flow.Flow): + """ + Removes the flow from the underlying store and the view. + """ + if f.id in self._store: + del self._store[f.id] + if f in self._view: + self._view.remove(f) + self.sig_remove.send(self, flow=f) + + def update(self, f: mitmproxy.flow.Flow): + """ + Updates a flow. If the flow is not in the state, it's ignored. + """ + if f.id in self._store: + if self.filter(f): + if f not in self._view: + self._view.add(f) + self.sig_add.send(self, flow=f) + else: + self.sig_update.send(self, flow=f) + else: + try: + self._view.remove(f) + self.sig_remove.send(self, flow=f) + except ValueError: + # The value was not in the view + pass + + # Event handlers + + def request(self, f): + self.add(f) + + def intercept(self, f): + self.update(f) + + def resume(self, f): + self.update(f) + + def error(self, f): + self.update(f) + + def response(self, f): + self.update(f) + + +class Focus: + """ + Tracks a focus element within a View. + """ + def __init__(self, v: View) -> None: + self.view = v + self._flow = None + if len(self.view): + self.flow = self.view[0] + v.sig_add.connect(self._sig_add) + v.sig_remove.connect(self._sig_remove) + v.sig_refresh.connect(self._sig_refresh) + + @property + def flow(self) -> typing.Optional[mitmproxy.flow.Flow]: + return self._flow + + @flow.setter + def flow(self, f: mitmproxy.flow.Flow): + if f is not None and f not in self.view: + raise ValueError("Attempt to set focus to flow not in view") + self._flow = f + + @property + def index(self) -> typing.Optional[int]: + if self.flow: + return self.view.index(self.flow) + + def next(self): + """ + Sets the focus to the next flow. + """ + if self.flow: + idx = min(self.index + 1, len(self.view) - 1) + self.flow = self.view[idx] + + def prev(self): + """ + Sets the focus to the previous flow. + """ + if self.flow: + idx = max(self.index - 1, 0) + self.flow = self.view[idx] + + def _nearest(self, f, v): + return min(v.bisect(f), len(v) - 1) + + def _sig_remove(self, view, flow): + if len(view) == 0: + self.flow = None + elif flow is self.flow: + self.flow = view[self._nearest(self.flow, view)] + + def _sig_refresh(self, view): + if len(view) == 0: + self.flow = None + elif self.flow is None: + self.flow = view[0] + elif self.flow not in view: + self.flow = view[self._nearest(self.flow, view)] + + def _sig_add(self, view, flow): + # We only have to act if we don't have a focus element + if not self.flow: + self.flow = flow + + +class Settings(collections.Mapping): + def __init__(self, view: View) -> None: + self.view = view + self.values = {} + view.sig_remove.connect(self._sig_remove) + view.sig_refresh.connect(self._sig_refresh) + + def __iter__(self) -> typing.Iterable: + return iter(self.values) + + def __len__(self) -> int: + return len(self.values) + + def __getitem__(self, f: mitmproxy.flow.Flow) -> dict: + if f.id not in self.view._store: + raise KeyError + return self.values.setdefault(f.id, {}) + + def _sig_remove(self, view, flow): + if flow.id in self.values: + del self.values[flow.id] + + def _sig_refresh(self, view): + for fid in self.values.keys(): + if fid not in view._store: + del self.values[fid] diff --git a/setup.py b/setup.py index 70ff8b5d7..fd2919735 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,7 @@ setup( "urwid>=1.3.1, <1.4", "watchdog>=0.8.3, <0.9", "brotlipy>=0.5.1, <0.7", + "sortedcontainers>=1.5.4, <1.6", ], extras_require={ ':sys_platform == "win32"': [ diff --git a/test/mitmproxy/addons/test_view.py b/test/mitmproxy/addons/test_view.py new file mode 100644 index 000000000..15cf534e5 --- /dev/null +++ b/test/mitmproxy/addons/test_view.py @@ -0,0 +1,275 @@ +from mitmproxy.addons import view +from mitmproxy import flowfilter + +from .. import tutils + + +def test_simple(): + v = view.View() + f = tutils.tflow() + f.request.timestamp_start = 1 + v.request(f) + assert list(v) == [f] + v.request(f) + assert list(v) == [f] + assert len(v._store) == 1 + + f2 = tutils.tflow() + f2.request.timestamp_start = 3 + v.request(f2) + assert list(v) == [f, f2] + v.request(f2) + assert list(v) == [f, f2] + assert len(v._store) == 2 + + f3 = tutils.tflow() + f3.request.timestamp_start = 2 + v.request(f3) + assert list(v) == [f, f3, f2] + v.request(f3) + assert list(v) == [f, f3, f2] + assert len(v._store) == 3 + + +def tft(*, method="get", start=0): + f = tutils.tflow() + f.request.method = method + f.request.timestamp_start = start + return f + + +def test_filter(): + v = view.View() + f = flowfilter.parse("~m get") + v.request(tft(method="get")) + v.request(tft(method="put")) + v.request(tft(method="get")) + v.request(tft(method="put")) + assert(len(v)) == 4 + v.set_filter(f) + assert [i.request.method for i in v] == ["GET", "GET"] + assert len(v._store) == 4 + v.set_filter(None) + + +def test_order(): + v = view.View() + v.request(tft(method="get", start=1)) + v.request(tft(method="put", start=2)) + v.request(tft(method="get", start=3)) + v.request(tft(method="put", start=4)) + assert [i.request.timestamp_start for i in v] == [1, 2, 3, 4] + + v.set_order(view.key_request_method) + assert [i.request.method for i in v] == ["GET", "GET", "PUT", "PUT"] + v.toggle_reversed() + assert [i.request.method for i in v] == ["PUT", "PUT", "GET", "GET"] + + v.set_order(view.key_request_start) + assert [i.request.timestamp_start for i in v] == [4, 3, 2, 1] + + v.toggle_reversed() + assert [i.request.timestamp_start for i in v] == [1, 2, 3, 4] + + +def test_reversed(): + v = view.View() + v.request(tft(start=1)) + v.request(tft(start=2)) + v.request(tft(start=3)) + v.toggle_reversed() + + assert v[0].request.timestamp_start == 3 + assert v[-1].request.timestamp_start == 1 + assert v[2].request.timestamp_start == 1 + tutils.raises(IndexError, v.__getitem__, 5) + tutils.raises(IndexError, v.__getitem__, -5) + + assert v.bisect(v[0]) == 1 + assert v.bisect(v[2]) == 3 + + +def test_update(): + v = view.View() + flt = flowfilter.parse("~m get") + v.set_filter(flt) + + f = tft(method="get") + v.request(f) + assert f in v + + f.request.method = "put" + v.update(f) + assert f not in v + + f.request.method = "get" + v.update(f) + assert f in v + + v.update(f) + assert f in v + + +class Record: + def __init__(self): + self.calls = [] + + def __bool__(self): + return bool(self.calls) + + def __repr__(self): + return repr(self.calls) + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + +def test_signals(): + v = view.View() + rec_add = Record() + rec_update = Record() + rec_remove = Record() + rec_refresh = Record() + + def clearrec(): + rec_add.calls = [] + rec_update.calls = [] + rec_remove.calls = [] + rec_refresh.calls = [] + + v.sig_add.connect(rec_add) + v.sig_update.connect(rec_update) + v.sig_remove.connect(rec_remove) + v.sig_refresh.connect(rec_refresh) + + assert not any([rec_add, rec_update, rec_remove, rec_refresh]) + + # Simple add + v.add(tft()) + assert rec_add + assert not any([rec_update, rec_remove, rec_refresh]) + + # Filter change triggers refresh + clearrec() + v.set_filter(flowfilter.parse("~m put")) + assert rec_refresh + assert not any([rec_update, rec_add, rec_remove]) + + v.set_filter(flowfilter.parse("~m get")) + + # An update that results in a flow being added to the view + clearrec() + v[0].request.method = "PUT" + v.update(v[0]) + assert rec_remove + assert not any([rec_update, rec_refresh, rec_add]) + + # An update that does not affect the view just sends update + v.set_filter(flowfilter.parse("~m put")) + clearrec() + v.update(v[0]) + assert rec_update + assert not any([rec_remove, rec_refresh, rec_add]) + + # An update for a flow in state but not view does not do anything + f = v[0] + v.set_filter(flowfilter.parse("~m get")) + assert not len(v) + clearrec() + v.update(f) + assert not any([rec_add, rec_update, rec_remove, rec_refresh]) + + +def test_focus(): + # Special case - initialising with a view that already contains data + v = view.View() + v.add(tft()) + f = view.Focus(v) + assert f.index is 0 + assert f.flow is v[0] + + # Start empty + v = view.View() + f = view.Focus(v) + assert f.index is None + assert f.flow is None + + v.add(tft(start=1)) + assert f.index == 0 + assert f.flow is v[0] + + v.add(tft(start=0)) + assert f.index == 1 + assert f.flow is v[1] + + v.add(tft(start=2)) + assert f.index == 1 + assert f.flow is v[1] + + v.remove(v[1]) + assert f.index == 1 + assert f.flow is v[1] + + v.remove(v[1]) + assert f.index == 0 + assert f.flow is v[0] + + v.remove(v[0]) + assert f.index is None + assert f.flow is None + + v.add(tft(method="get", start=0)) + v.add(tft(method="get", start=1)) + v.add(tft(method="put", start=2)) + v.add(tft(method="get", start=3)) + + f.flow = v[2] + assert f.flow.request.method == "PUT" + + filt = flowfilter.parse("~m get") + v.set_filter(filt) + assert f.index == 2 + + filt = flowfilter.parse("~m oink") + v.set_filter(filt) + assert f.index is None + + +def test_focus_nextprev(): + v = view.View() + # Nops on an empty view + v.focus.next() + v.focus.prev() + + # Nops on a single-flow view + v.add(tft(start=0)) + assert v.focus.flow == v[0] + v.focus.next() + assert v.focus.flow == v[0] + v.focus.prev() + assert v.focus.flow == v[0] + + v.add(tft(start=1)) + v.focus.next() + assert v.focus.flow == v[1] + v.focus.next() + assert v.focus.flow == v[1] + v.focus.prev() + assert v.focus.flow == v[0] + v.focus.prev() + assert v.focus.flow == v[0] + + +def test_settings(): + v = view.View() + f = tft() + + tutils.raises(KeyError, v.settings.__getitem__, f) + v.add(f) + assert v.settings[f] == {} + v.settings[f]["foo"] = "bar" + assert v.settings[f]["foo"] == "bar" + assert len(list(v.settings)) == 1 + v.remove(f) + tutils.raises(KeyError, v.settings.__getitem__, f) + assert not v.settings.keys()