Browse Source

Import new rpc confirmation window code

Import unmodified implementation done by @boring-stuff.
Full history for reference is available in rpc-confirmation-window
branch.

QubesOS/qubes-issues#910
Marek Marczykowski-Górecki 7 years ago
parent
commit
b3ceb2d7fa

+ 359 - 0
qubespolicy/glade/RPCConfirmationWindow.glade

@@ -0,0 +1,359 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.18.3 -->
+<interface>
+  <requires lib="gtk+" version="3.12"/>
+  <object class="GtkWindow" id="RPCConfirmationWindow">
+    <property name="width_request">400</property>
+    <property name="can_focus">False</property>
+    <property name="title" translatable="yes">Operation execution</property>
+    <property name="window_position">center</property>
+    <property name="icon_name">dialog-question</property>
+    <property name="type_hint">dialog</property>
+    <property name="urgency_hint">True</property>
+    <property name="gravity">center</property>
+    <child>
+      <object class="GtkBox" id="WindowBox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkInfoBar" id="ErrorBar">
+            <property name="app_paintable">True</property>
+            <property name="can_focus">False</property>
+            <property name="no_show_all">True</property>
+            <property name="message_type">error</property>
+            <property name="show_close_button">True</property>
+            <child internal-child="action_area">
+              <object class="GtkButtonBox" id="ActionArea">
+                <property name="can_focus">False</property>
+                <property name="spacing">6</property>
+                <property name="layout_style">end</property>
+                <child>
+                  <placeholder/>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child internal-child="content_area">
+              <object class="GtkBox" id="ContentArea">
+                <property name="can_focus">False</property>
+                <property name="spacing">16</property>
+                <child>
+                  <object class="GtkImage" id="ErrorImage">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="stock">gtk-dialog-error</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkLabel" id="ErrorMessage">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">ErrorMessage</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="MainBox">
+            <property name="width_request">100</property>
+            <property name="height_request">80</property>
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="margin_left">12</property>
+            <property name="margin_right">12</property>
+            <property name="margin_top">12</property>
+            <property name="margin_bottom">12</property>
+            <property name="vexpand">True</property>
+            <property name="orientation">vertical</property>
+            <property name="spacing">6</property>
+            <child>
+              <object class="GtkButtonBox" id="ButtonBox">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="spacing">6</property>
+                <property name="layout_style">end</property>
+                <child>
+                  <object class="GtkButton" id="cancelButton">
+                    <property name="label">gtk-cancel</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="receives_default">True</property>
+                    <property name="use_stock">True</property>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkButton" id="okButton">
+                    <property name="label">gtk-ok</property>
+                    <property name="visible">True</property>
+                    <property name="sensitive">False</property>
+                    <property name="can_focus">True</property>
+                    <property name="can_default">True</property>
+                    <property name="receives_default">True</property>
+                    <property name="use_stock">True</property>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="pack_type">end</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkBox" id="ContentBox">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="orientation">vertical</property>
+                <property name="spacing">6</property>
+                <child>
+                  <object class="GtkBox" id="AlwaysShownBox">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="margin_bottom">6</property>
+                    <property name="spacing">12</property>
+                    <child>
+                      <object class="GtkImage" id="RPCConfirmationIcon">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="stock">gtk-dialog-question</property>
+                        <property name="icon_size">6</property>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="rpcDescription">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="halign">start</property>
+                        <property name="label" translatable="yes">Do you want to allow the following operation?
+&lt;small&gt;Select the target domain and confirm with 'OK'&lt;/small&gt;</property>
+                        <property name="use_markup">True</property>
+                      </object>
+                      <packing>
+                        <property name="expand">True</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkGrid" id="grid1">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="margin_left">12</property>
+                    <property name="margin_bottom">6</property>
+                    <property name="row_spacing">12</property>
+                    <property name="column_spacing">6</property>
+                    <child>
+                      <object class="GtkLabel" id="TargetDescLabel">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="xalign">1</property>
+                        <property name="label" translatable="yes">Target:</property>
+                      </object>
+                      <packing>
+                        <property name="left_attach">0</property>
+                        <property name="top_attach">2</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkComboBox" id="TargetCombo">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="hexpand">True</property>
+                        <property name="has_entry">True</property>
+                        <child internal-child="entry">
+                          <object class="GtkEntry" id="TargetComboEntry">
+                            <property name="can_focus">True</property>
+                            <property name="width_chars">5</property>
+                            <property name="primary_icon_activatable">False</property>
+                            <property name="placeholder_text" translatable="yes">Start typing or use the arrow</property>
+                            <property name="input_hints">GTK_INPUT_HINT_WORD_COMPLETION | GTK_INPUT_HINT_NONE</property>
+                          </object>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="left_attach">1</property>
+                        <property name="top_attach">2</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="sourceDescLabel">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="xalign">1</property>
+                        <property name="label" translatable="yes">Source:</property>
+                      </object>
+                      <packing>
+                        <property name="left_attach">0</property>
+                        <property name="top_attach">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkEntry" id="sourceEntry">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="editable">False</property>
+                        <property name="text" translatable="yes">source</property>
+                        <property name="primary_icon_activatable">False</property>
+                      </object>
+                      <packing>
+                        <property name="left_attach">1</property>
+                        <property name="top_attach">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="operationDescLabel">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="xalign">1</property>
+                        <property name="label" translatable="yes">Operation:</property>
+                      </object>
+                      <packing>
+                        <property name="left_attach">0</property>
+                        <property name="top_attach">1</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="rpcLabel">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="xalign">0</property>
+                        <property name="label" translatable="yes">qubes.&lt;b&gt;MyOperation&lt;/b&gt;</property>
+                        <property name="use_markup">True</property>
+                      </object>
+                      <packing>
+                        <property name="left_attach">1</property>
+                        <property name="top_attach">1</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkExpander" id="AdvancedSection">
+                    <property name="can_focus">True</property>
+                    <property name="no_show_all">True</property>
+                    <child>
+                      <object class="GtkBox" id="AdvancedOptions">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="margin_left">6</property>
+                        <property name="orientation">vertical</property>
+                        <property name="spacing">6</property>
+                        <child>
+                          <object class="GtkCheckButton" id="DisplayTemplates">
+                            <property name="label" translatable="yes">Display templates in the target list</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="receives_default">False</property>
+                            <property name="xalign">0</property>
+                            <property name="draw_indicator">True</property>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">True</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <object class="GtkCheckButton" id="CustomLocation">
+                            <property name="label" translatable="yes">Choose a custom destination in the target</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="receives_default">False</property>
+                            <property name="xalign">0</property>
+                            <property name="draw_indicator">True</property>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">True</property>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
+                      </object>
+                    </child>
+                    <child type="label">
+                      <object class="GtkLabel" id="AdvancedLabel">
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Advanced options</property>
+                        <property name="use_underline">True</property>
+                      </object>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">2</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </object>
+</interface>

+ 301 - 0
qubespolicy/gtkhelpers.py

@@ -0,0 +1,301 @@
+#!/usr/bin/python
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2017 boring-stuff <boring-stuff@users.noreply.github.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+
+import qubes
+import gi, os
+gi.require_version('Gtk', '3.0')
+from gi.repository import Gtk, Gdk, GdkPixbuf, GObject, GLib
+from . qubesutils import sanitize_domain_name
+
+glade_directory = os.path.join(os.path.dirname(__file__), "glade")
+
+class GtkIconGetter:
+    def __init__(self, size):
+        self._icons = {}
+        self._size = size
+        self._theme = Gtk.IconTheme.get_default()
+
+    def get_icon(self, name):
+        if name not in self._icons:
+            try:
+                icon = self._theme.load_icon(name, self._size, 0)
+            except GLib.Error:
+                icon = self._theme.load_icon("gnome-foot", self._size, 0)
+
+            self._icons[name] = icon
+
+        return self._icons[name]
+
+class VMListModeler:
+    def __init__(self):
+        self._icon_getter = GtkIconGetter(16)
+
+        self._entries = {}
+        self._create_entries()
+
+
+    def _get_icon(self, vm):
+        return self._icon_getter.get_icon(vm.label.icon)
+
+    def _get_list(self):
+        collection = qubes.QubesVmCollection()
+        try:
+            collection.lock_db_for_reading()
+
+            collection.load()
+
+            return [vm for vm in collection.values()]
+        finally:
+            collection.unlock_db()
+
+    def _create_entries(self):
+        for vm in self._get_list():
+            sanitize_domain_name(vm.name, assert_sanitized = True)
+
+            icon = self._get_icon(vm)
+
+            self._entries[vm.name] = {'qid': vm.qid,
+                                      'icon': icon,
+                                      'vm': vm}
+
+
+    def _get_valid_qube_name(self, combo, entry_box, exclusions):
+        name = None
+
+        if combo and combo.get_active_id():
+            selected = combo.get_active_id()
+
+            if selected in self._entries and selected not in exclusions:
+                name = selected
+
+        if not name and entry_box:
+            typed = entry_box.get_text()
+
+            if typed in self._entries and typed not in exclusions:
+                name = typed
+
+        return name
+
+    def _combo_change(self, selection_trigger, combo, entry_box, exclusions):
+        data = None
+        name = self._get_valid_qube_name(combo, entry_box, exclusions)
+
+        if name:
+            entry = self._entries[name]
+
+            data = (entry['qid'], name)
+
+            if entry_box:
+                entry_box.set_icon_from_pixbuf(
+                    Gtk.EntryIconPosition.PRIMARY, entry['icon'])
+        else:
+            if entry_box:
+                entry_box.set_icon_from_stock(
+                    Gtk.EntryIconPosition.PRIMARY, "gtk-find")
+
+        if selection_trigger:
+            selection_trigger(data)
+
+    def _entry_activate(self, activation_trigger, combo, entry_box, exclusions):
+        name = self._get_valid_qube_name(combo, entry_box, exclusions)
+
+        if name:
+            activation_trigger(entry_box)
+
+    def apply_model(self, destination_object, vm_filter_list = None,
+                    selection_trigger = None, activation_trigger = None):
+        if isinstance(destination_object, Gtk.ComboBox):
+            list_store = Gtk.ListStore(int, str, GdkPixbuf.Pixbuf)
+
+            exclusions = []
+            for vm_name in sorted(self._entries.iterkeys()):
+                entry = self._entries[vm_name]
+
+                matches = True
+
+                if vm_filter_list:
+                    for vm_filter in vm_filter_list:
+                        if not vm_filter.matches(entry['vm']):
+                            matches = False
+                            break
+
+                if matches:
+                    list_store.append([entry['qid'], vm_name, entry['icon']])
+                else:
+                    exclusions += [vm_name]
+
+            destination_object.set_model(list_store)
+            destination_object.set_id_column(1)
+
+            icon_column = Gtk.CellRendererPixbuf()
+            destination_object.pack_start(icon_column, False)
+            destination_object.add_attribute(icon_column, "pixbuf", 2)
+            destination_object.set_entry_text_column(1)
+
+            if destination_object.get_has_entry():
+                entry_box = destination_object.get_child()
+
+                area = Gtk.CellAreaBox()
+                area.pack_start(icon_column, False, False, False)
+                area.add_attribute(icon_column, "pixbuf", 2)
+
+                completion = Gtk.EntryCompletion.new_with_area(area)
+                completion.set_inline_selection(True)
+                completion.set_inline_completion(True)
+                completion.set_popup_completion(True)
+                completion.set_popup_single_match(False)
+                completion.set_model(list_store)
+                completion.set_text_column(1)
+
+                entry_box.set_completion(completion)
+                if activation_trigger:
+                    entry_box.connect("activate",
+                        lambda entry: self._entry_activate(
+                            activation_trigger,
+                            destination_object,
+                            entry,
+                            exclusions))
+
+                # A Combo with an entry has a text column already
+                text_column = destination_object.get_cells()[0]
+                destination_object.reorder(text_column, 1)
+            else:
+                entry_box = None
+
+                text_column = Gtk.CellRendererText()
+                destination_object.pack_start(text_column, False)
+                destination_object.add_attribute(text_column, "text", 1)
+
+            changed_function = lambda combo: self._combo_change(
+                             selection_trigger,
+                             combo,
+                             entry_box,
+                             exclusions)
+
+            destination_object.connect("changed", changed_function)
+            changed_function(destination_object)
+
+        else:
+            raise TypeError(
+                    "Only expecting Gtk.ComboBox objects to want our model.")
+
+    def apply_icon(self, entry, qube_name):
+        if isinstance(entry, Gtk.Entry):
+            if qube_name in self._entries:
+                entry.set_icon_from_pixbuf(
+                        Gtk.EntryIconPosition.PRIMARY,
+                        self._entries[qube_name]['icon'])
+            else:
+                raise ValueError("The specified source qube does not exist!")
+        else:
+            raise TypeError(
+                    "Only expecting Gtk.Entry objects to want our icon.")
+
+    class NameBlacklistFilter:
+        def __init__(self, avoid_names_list):
+            self._avoid_names_list = avoid_names_list
+
+        def matches(self, vm):
+            return vm.name not in self._avoid_names_list
+
+    class NameWhitelistFilter:
+        def __init__(self, allowed_names_list):
+            self._allowed_names_list = allowed_names_list
+
+        def matches(self, vm):
+            return vm.name in self._allowed_names_list
+
+class GtkOneTimerHelper:
+    def __init__(self, wait_seconds):
+        self._wait_seconds = wait_seconds
+        self._current_timer_id = 0
+        self._timer_completed = False
+
+    def _invalidate_timer_completed(self):
+        self._timer_completed = False
+
+    def _invalidate_current_timer(self):
+        self._current_timer_id += 1
+
+    def _timer_check_run(self, timer_id):
+        if self._current_timer_id == timer_id:
+            self._timer_run(timer_id)
+            self._timer_completed = True
+        else:
+            pass
+
+    def _timer_run(self, timer_id):
+        raise NotImplementedError("Not yet implemented")
+
+    def _timer_schedule(self):
+        self._invalidate_current_timer()
+        GObject.timeout_add(int(round(self._wait_seconds * 1000)),
+                            self._timer_check_run,
+                            self._current_timer_id)
+
+    def _timer_has_completed(self):
+        return self._timer_completed
+
+class FocusStealingHelper(GtkOneTimerHelper):
+    def __init__(self, window, target_button, wait_seconds = 1):
+        GtkOneTimerHelper.__init__(self, wait_seconds)
+        self._window = window
+        self._target_button = target_button
+
+        self._window.connect("window-state-event", self._window_state_event)
+
+        self._target_sensitivity = False
+        self._target_button.set_sensitive(self._target_sensitivity)
+
+    def _window_changed_focus(self, window_is_focused):
+        self._target_button.set_sensitive(False)
+        self._invalidate_timer_completed()
+
+        if window_is_focused:
+            self._timer_schedule()
+        else:
+            self._invalidate_current_timer()
+
+    def _window_state_event(self, window, event):
+        assert window == self._window, \
+               'Window state callback called with wrong window'
+
+        changed_focus = event.changed_mask & Gdk.WindowState.FOCUSED
+        window_focus = event.new_window_state & Gdk.WindowState.FOCUSED
+
+        if changed_focus:
+            self._window_changed_focus(window_focus != 0)
+
+        # Propagate event further
+        return False
+
+    def _timer_run(self, timer_id):
+        self._target_button.set_sensitive(self._target_sensitivity)
+
+    def request_sensitivity(self, sensitivity):
+        if self._timer_has_completed() or not sensitivity:
+            self._target_button.set_sensitive(sensitivity)
+
+        self._target_sensitivity = sensitivity
+
+    def can_perform_action(self):
+        return self._timer_has_completed()
+

+ 210 - 0
qubespolicy/rpcconfirmation.py

@@ -0,0 +1,210 @@
+#!/usr/bin/python
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2017 boring-stuff <boring-stuff@users.noreply.github.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+
+from . gtkhelpers import VMListModeler, FocusStealingHelper, glade_directory
+from . qubesutils import sanitize_domain_name, sanitize_service_name
+from gi.repository import Gtk, Gdk, GLib
+import os
+
+class RPCConfirmationWindow():
+    _source_file = os.path.join(glade_directory, "RPCConfirmationWindow.glade")
+    _source_id = { 'window': "RPCConfirmationWindow",
+                  'ok': "okButton",
+                  'cancel': "cancelButton",
+                  'source': "sourceEntry",
+                  'rpc_label' : "rpcLabel",
+                  'target': "TargetCombo",
+                  'error_bar': "ErrorBar",
+                  'error_message': "ErrorMessage",
+                }
+
+    def _clicked_ok(self, source):
+        assert source != None, \
+               'Called the clicked ok callback from no source object'
+
+        if self._can_perform_action():
+            self._confirmed = True
+            self._close()
+
+    def _clicked_cancel(self, button):
+        assert button == self._rpc_cancel_button, \
+               'Called the clicked cancel callback through the wrong button'
+
+        if self._can_perform_action():
+            self._confirmed = False
+            self._close()
+
+    def _key_pressed(self, window, key):
+        assert window == self._rpc_window, \
+               'Key pressed callback called with wrong window'
+
+        if self._can_perform_action():
+            if key.keyval == Gdk.KEY_Escape:
+                self._confirmed = False
+                self._close()
+
+    def _update_ok_button_sensitivity(self, data):
+        valid = (data != None)
+
+        if valid:
+            (self._target_qid, self._target_name) = data
+        else:
+            self._target_qid = None
+            self._target_name = None
+
+        self._focus_helper.request_sensitivity(valid)
+
+    def _show_error(self, error_message):
+        self._error_message.set_text(error_message)
+        self._error_bar.set_visible(True)
+
+    def _close_error(self, error_bar, response):
+        assert error_bar == self._error_bar, \
+               'Closed the error bar with the wrong error bar as parameter'
+        assert response != None, 'Closed the error bar with None as a response'
+
+        self._error_bar.set_visible(False)
+
+    def _set_initial_target(self, source, target):
+        if target != None:
+            if target == source:
+                self._show_error(
+                     "Source and target domains must not be the same.")
+            else:
+                model = self._rpc_combo_box.get_model()
+
+                found = False
+                for item in model:
+                    if item[1] == target:
+                        found = True
+
+                        self._rpc_combo_box.set_active_iter(
+                                    model.get_iter(item.path))
+
+                        break
+
+                if not found:
+                    self._show_error("Domain '%s' doesn't exist." % target)
+
+    def _can_perform_action(self):
+        return self._focus_helper.can_perform_action()
+
+    def _escape_and_format_rpc_text(self, rpc_operation):
+        escaped = GLib.markup_escape_text(rpc_operation)
+
+        partitioned = escaped.partition('.')
+        formatted = partitioned[0] + partitioned[1]
+
+        if len(partitioned[2]) > 0:
+            formatted += "<b>" + partitioned[2] + "</b>"
+        else:
+            formatted = "<b>" + formatted + "</b>"
+
+        return formatted
+
+    def _connect_events(self):
+        self._rpc_window.connect("key-press-event",self._key_pressed)
+        self._rpc_ok_button.connect("clicked", self._clicked_ok)
+        self._rpc_cancel_button.connect("clicked", self._clicked_cancel)
+
+        self._error_bar.connect("response", self._close_error)
+
+    def __init__(self, source, rpc_operation, name_whitelist, target = None):
+        sanitize_domain_name(source, assert_sanitized = True)
+        sanitize_service_name(source, assert_sanitized = True)
+
+        self._gtk_builder = Gtk.Builder()
+        self._gtk_builder.add_from_file(self._source_file)
+        self._rpc_window = self._gtk_builder.get_object(
+                                            self._source_id['window'])
+        self._rpc_ok_button = self._gtk_builder.get_object(
+                                            self._source_id['ok'])
+        self._rpc_cancel_button = self._gtk_builder.get_object(
+                                            self._source_id['cancel'])
+        self._rpc_label = self._gtk_builder.get_object(
+                                            self._source_id['rpc_label'])
+        self._source_entry = self._gtk_builder.get_object(
+                                            self._source_id['source'])
+        self._rpc_combo_box = self._gtk_builder.get_object(
+                                            self._source_id['target'])
+        self._error_bar = self._gtk_builder.get_object(
+                                            self._source_id['error_bar'])
+        self._error_message = self._gtk_builder.get_object(
+                                            self._source_id['error_message'])
+        self._target_qid = None
+        self._target_name = None
+
+        self._focus_helper = self._new_focus_stealing_helper()
+
+        self._rpc_label.set_markup(
+                    self._escape_and_format_rpc_text(rpc_operation))
+
+        list_modeler = self._new_VM_list_modeler()
+
+        domain_filters = [VMListModeler.NameWhitelistFilter(name_whitelist)]
+
+        list_modeler.apply_model(self._rpc_combo_box, domain_filters,
+                    selection_trigger = self._update_ok_button_sensitivity,
+                    activation_trigger = self._clicked_ok )
+
+        self._source_entry.set_text(source)
+        list_modeler.apply_icon(self._source_entry, source)
+
+        self._confirmed = None
+
+        self._set_initial_target(source, target)
+
+        self._connect_events()
+
+    def _close(self):
+        self._rpc_window.close()
+
+    def _show(self):
+        self._rpc_window.set_keep_above(True)
+        self._rpc_window.connect("delete-event", Gtk.main_quit)
+        self._rpc_window.show_all()
+
+        Gtk.main()
+
+    def _new_VM_list_modeler(self):
+        return VMListModeler()
+
+    def _new_focus_stealing_helper(self):
+        return FocusStealingHelper(
+                    self._rpc_window,
+                    self._rpc_ok_button,
+                    1)
+
+    def confirm_rpc(self):
+        self._show()
+
+        if self._confirmed:
+            return { 'name': self._target_name, 'qid': self._target_qid,
+                     'parameters': {} }
+        else:
+            return False
+
+def confirm_rpc(source, rpc_operation, name_whitelist, target = None):
+    window = RPCConfirmationWindow(source, rpc_operation, name_whitelist,
+                                   target)
+
+    return window.confirm_rpc()
+

+ 444 - 0
qubespolicy/tests/gtkhelpers.py

@@ -0,0 +1,444 @@
+#!/usr/bin/python
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2017 boring-stuff <boring-stuff@users.noreply.github.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+
+import unittest, time
+from core.gtkhelpers import VMListModeler, GtkOneTimerHelper, FocusStealingHelper, Gtk
+
+class VMListModelerMock(VMListModeler):
+    def __init__(self):
+        VMListModeler.__init__(self)
+
+    def _get_list(self):
+        return [  MockVm(0, "dom0", "black"),
+                        MockVm(2, "test-red1", "red"),
+                        MockVm(4, "test-red2", "red"),
+                        MockVm(7, "test-red3", "red"),
+                        MockVm(8, "test-source", "green"),
+                        MockVm(10, "test-target", "orange"),
+                        MockVm(15, "test-disp6", "red", True) ]
+
+    @staticmethod
+    def get_name_whitelist():
+        return [ "test-red1", "test-red2", "test-red3",
+                 "test-target", "test-disp6" ]
+
+class MockVmLabel:
+    def __init__(self, index, color, name, dispvm = False):
+        self.index = index
+        self.color = color
+        self.name = name
+        self.dispvm = dispvm
+        self.icon = "gnome-foot"
+
+class MockVm:
+    def __init__(self, qid, name, color, dispvm = False):
+        self.qid = qid
+        self.name = name
+        self.label = MockVmLabel(qid, 0x000000, color, dispvm)
+
+class MockComboEntry:
+    def __init__(self, text):
+        self._text = text
+
+    def get_active_id(self):
+        return self._text
+
+    def get_text(self):
+        return self._text
+
+class GtkTestCase(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        unittest.TestCase.__init__(self, *args, **kwargs)
+        self._smallest_wait = 0.01
+
+    def flush_gtk_events(self, wait_seconds = 0):
+        start = time.time()
+        iterations = 0
+        remaining_wait = wait_seconds
+        time_length = 0
+
+        if wait_seconds < 0:
+            raise ValueError("Only non-negative intervals are allowed.")
+
+        while remaining_wait >= 0:
+            while Gtk.events_pending():
+                Gtk.main_iteration_do(blocking = False)
+                iterations += 1
+
+            time_length = time.time() - start
+            remaining_wait = wait_seconds - time_length
+
+            if (remaining_wait > 0):
+                time.sleep(self._smallest_wait)
+
+        return (iterations, time_length)
+
+class VMListModelerTest(VMListModelerMock, unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        unittest.TestCase.__init__(self, *args, **kwargs)
+        VMListModelerMock.__init__(self)
+
+    def test_entries_gets_loaded(self):
+        self.assertIsNotNone(self._entries)
+
+    def test_valid_qube_name(self):
+        self.apply_model(Gtk.ComboBox())
+
+        for name in [ "test-red1", "test-red2", "test-red3",
+                      "test-target", "test-disp6" ]:
+
+            mock = MockComboEntry(name)
+            self.assertEquals(name, self._get_valid_qube_name(mock, mock, []))
+            self.assertEquals(name, self._get_valid_qube_name(None, mock, []))
+            self.assertEquals(name, self._get_valid_qube_name(mock, None, []))
+            self.assertIsNone(self._get_valid_qube_name(None, None, []))
+
+    def test_valid_qube_name_exceptions(self):
+        list_exc = ["test-disp6", "test-red2"]
+
+        self.apply_model(Gtk.ComboBox(),
+            [VMListModeler.NameBlacklistFilter([ list_exc[0], list_exc[1] ]) ])
+
+        for name in list_exc:
+            mock = MockComboEntry(name)
+            self.assertIsNone(self._get_valid_qube_name(mock, mock, list_exc))
+            self.assertIsNone(self._get_valid_qube_name(None, mock, list_exc))
+            self.assertIsNone(self._get_valid_qube_name(mock, None, list_exc))
+
+    def test_invalid_qube_name(self):
+        self.apply_model(Gtk.ComboBox())
+
+        for name in [ "test-nonexistant", None, "", 1 ]:
+
+            mock = MockComboEntry(name)
+            self.assertIsNone(self._get_valid_qube_name(mock, mock, []))
+            self.assertIsNone(self._get_valid_qube_name(None, mock, []))
+            self.assertIsNone(self._get_valid_qube_name(mock, None, []))
+
+    def test_apply_model(self):
+        new_object = Gtk.ComboBox()
+        self.assertIsNone(new_object.get_model())
+
+        self.apply_model(new_object)
+
+        self.assertIsNotNone(new_object.get_model())
+
+    def test_apply_model_with_entry(self):
+        new_object = Gtk.ComboBox.new_with_entry()
+
+        self.assertIsNone(new_object.get_model())
+
+        self.apply_model(new_object)
+
+        self.assertIsNotNone(new_object.get_model())
+
+    def test_apply_model_only_combobox(self):
+        invalid_types = [ 1, "One", u'1', {'1': "one"}, VMListModelerMock()]
+
+        for invalid_type in invalid_types:
+            with self.assertRaises(TypeError):
+                self.apply_model(invalid_type)
+
+    def test_apply_model_blacklist(self):
+        combo = Gtk.ComboBox()
+
+        self.apply_model(combo)
+        self.assertEquals(7, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameBlacklistFilter([
+                                        self._entries.keys()[0] ]) ])
+        self.assertEquals(6, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameBlacklistFilter([
+                                        self._entries.keys()[0] ]),
+                                    VMListModeler.NameBlacklistFilter([
+                                        self._entries.keys()[1] ]) ])
+        self.assertEquals(5, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameBlacklistFilter([
+                                        self._entries.keys()[0],
+                                        self._entries.keys()[1] ]) ])
+        self.assertEquals(5, len(combo.get_model()))
+
+    def test_apply_model_whitelist(self):
+        combo = Gtk.ComboBox()
+
+        self.apply_model(combo)
+        self.assertEquals(7, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameWhitelistFilter([
+                                        self._entries.keys()[0] ]) ])
+        self.assertEquals(1, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameWhitelistFilter([
+                                        self._entries.keys()[0],
+                                        self._entries.keys()[1] ]) ])
+        self.assertEquals(2, len(combo.get_model()))
+
+    def test_apply_model_multiple_filters(self):
+        combo = Gtk.ComboBox()
+
+        self.apply_model(combo)
+        self.assertEquals(7, len(combo.get_model()))
+
+        self.apply_model(combo, [   VMListModeler.NameWhitelistFilter([
+                                        self._entries.keys()[0],
+                                        self._entries.keys()[1],
+                                        self._entries.keys()[2],
+                                        self._entries.keys()[3],
+                                        self._entries.keys()[4] ]),
+                                    VMListModeler.NameBlacklistFilter([
+                                        self._entries.keys()[0],
+                                        self._entries.keys()[1] ]) ])
+        self.assertEquals(3, len(combo.get_model()))
+
+    def test_apply_icon(self):
+        new_object = Gtk.Entry()
+
+        self.assertIsNone(
+                new_object.get_icon_pixbuf(Gtk.EntryIconPosition.PRIMARY))
+
+        self.apply_icon(new_object, "test-disp6")
+
+        self.assertIsNotNone(
+                new_object.get_icon_pixbuf(Gtk.EntryIconPosition.PRIMARY))
+
+    def test_apply_icon_only_entry(self):
+        invalid_types = [ 1, "One", u'1', {'1': "one"}, Gtk.ComboBox()]
+
+        for invalid_type in invalid_types:
+            with self.assertRaises(TypeError):
+                self.apply_icon(invalid_type, "test-disp6")
+
+    def test_apply_icon_only_existing(self):
+        new_object = Gtk.Entry()
+
+        for name in [ "test-red1", "test-red2", "test-red3",
+                      "test-target", "test-disp6" ]:
+            self.apply_icon(new_object, name)
+
+        for name in [ "test-nonexistant", None, "", 1 ]:
+            with self.assertRaises(ValueError):
+                self.apply_icon(new_object, name)
+
+class GtkOneTimerHelperTest(GtkOneTimerHelper, GtkTestCase):
+    def __init__(self, *args, **kwargs):
+        GtkTestCase.__init__(self, *args, **kwargs)
+
+        self._test_time = 0.1
+
+        GtkOneTimerHelper.__init__(self, self._test_time)
+        self._run_timers = []
+
+    def _timer_run(self, timer_id):
+        self._run_timers.append(timer_id)
+
+    def test_nothing_runs_automatically(self):
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([], self._run_timers)
+        self.assertEquals(0, self._current_timer_id)
+        self.assertFalse(self._timer_has_completed())
+
+    def test_schedule_one_task(self):
+        self._timer_schedule()
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1], self._run_timers)
+        self.assertEquals(1, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+    def test_invalidate_completed(self):
+        self._timer_schedule()
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1], self._run_timers)
+        self.assertEquals(1, self._current_timer_id)
+
+        self.assertTrue(self._timer_has_completed())
+        self._invalidate_timer_completed()
+        self.assertFalse(self._timer_has_completed())
+
+    def test_schedule_and_cancel_one_task(self):
+        self._timer_schedule()
+        self._invalidate_current_timer()
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([], self._run_timers)
+        self.assertEquals(2, self._current_timer_id)
+        self.assertFalse(self._timer_has_completed())
+
+    def test_two_tasks(self):
+        self._timer_schedule()
+        self.flush_gtk_events(self._test_time/4)
+        self._timer_schedule()
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([2], self._run_timers)
+        self.assertEquals(2, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+    def test_more_tasks(self):
+        num = 0
+        for num in range(1,10):
+            self._timer_schedule()
+            self.flush_gtk_events(self._test_time/4)
+        self.flush_gtk_events(self._test_time*1.75)
+        self.assertEquals([num], self._run_timers)
+        self.assertEquals(num, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+    def test_more_tasks_cancel(self):
+        num = 0
+        for num in range(1,10):
+            self._timer_schedule()
+            self.flush_gtk_events(self._test_time/4)
+        self._invalidate_current_timer()
+        self.flush_gtk_events(self._test_time*1.75)
+        self.assertEquals([], self._run_timers)
+        self.assertEquals(num+1, self._current_timer_id)
+        self.assertFalse(self._timer_has_completed())
+
+    def test_subsequent_tasks(self):
+        self._timer_schedule() #1
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1], self._run_timers)
+        self.assertEquals(1, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+        self._timer_schedule() #2
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1,2], self._run_timers)
+        self.assertEquals(2, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+        self._invalidate_timer_completed()
+        self._timer_schedule() #3
+        self._invalidate_current_timer() #4
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1,2], self._run_timers)
+        self.assertEquals(4, self._current_timer_id)
+        self.assertFalse(self._timer_has_completed())
+
+        self._timer_schedule() #5
+        self.flush_gtk_events(self._test_time*2)
+        self.assertEquals([1,2,5], self._run_timers)
+        self.assertEquals(5, self._current_timer_id)
+        self.assertTrue(self._timer_has_completed())
+
+class FocusStealingHelperMock(FocusStealingHelper):
+    def simulate_focus(self):
+        self._window_changed_focus(True)
+
+class FocusStealingHelperTest(FocusStealingHelperMock, GtkTestCase):
+    def __init__(self, *args, **kwargs):
+        GtkTestCase.__init__(self, *args, **kwargs)
+
+        self._test_time = 0.1
+        self._test_button = Gtk.Button()
+        self._test_window = Gtk.Window()
+
+        FocusStealingHelperMock.__init__(self, self._test_window,
+                                     self._test_button, self._test_time)
+
+    def test_nothing_runs_automatically(self):
+        self.assertFalse(self.can_perform_action())
+        self.flush_gtk_events(self._test_time*2)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+    def test_nothing_runs_automatically_with_request(self):
+        self.request_sensitivity(True)
+        self.assertFalse(self.can_perform_action())
+        self.flush_gtk_events(self._test_time*2)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+    def _simulate_focus(self, focused):
+        self._window_changed_focus(focused)
+
+    def test_focus_with_request(self):
+        self.request_sensitivity(True)
+        self._simulate_focus(True)
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertTrue(self._test_button.get_sensitive())
+
+    def test_focus_with_late_request(self):
+        self._simulate_focus(True)
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+        self.request_sensitivity(True)
+        self.assertTrue(self._test_button.get_sensitive())
+
+    def test_immediate_defocus(self):
+        self.request_sensitivity(True)
+        self._simulate_focus(True)
+        self._simulate_focus(False)
+        self.flush_gtk_events(self._test_time*2)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+    def test_focus_then_unfocus(self):
+        self.request_sensitivity(True)
+        self._simulate_focus(True)
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertTrue(self._test_button.get_sensitive())
+
+        self._simulate_focus(False)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+    def test_focus_cycle(self):
+        self.request_sensitivity(True)
+
+        self._simulate_focus(True)
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertTrue(self._test_button.get_sensitive())
+
+        self._simulate_focus(False)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+        self._simulate_focus(True)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertTrue(self._test_button.get_sensitive())
+
+        self.request_sensitivity(False)
+        self.assertTrue(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+        self._simulate_focus(False)
+        self.assertFalse(self.can_perform_action())
+
+        self._simulate_focus(True)
+        self.assertFalse(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+        self.flush_gtk_events(self._test_time*2)
+        self.assertTrue(self.can_perform_action())
+        self.assertFalse(self._test_button.get_sensitive())
+
+if __name__=='__main__':
+    unittest.main()

+ 334 - 0
qubespolicy/tests/rpcconfirmation.py

@@ -0,0 +1,334 @@
+#!/usr/bin/python
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2017 boring-stuff <boring-stuff@users.noreply.github.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+
+import unittest, sys
+from core.rpcconfirmation import RPCConfirmationWindow
+from gtkhelpers import VMListModelerMock, GtkTestCase, FocusStealingHelperMock
+
+class MockRPCConfirmationWindow(RPCConfirmationWindow):
+    def _new_VM_list_modeler(self):
+        return VMListModelerMock()
+
+    def _new_focus_stealing_helper(self):
+        return FocusStealingHelperMock(
+                    self._rpc_window,
+                    self._rpc_ok_button,
+                    self._focus_stealing_seconds)
+
+    def __init__(self, source, rpc_operation,
+                 name_whitelist = VMListModelerMock.get_name_whitelist(),
+                 target = None, focus_stealing_seconds = 1):
+        self._focus_stealing_seconds = focus_stealing_seconds
+
+        RPCConfirmationWindow.__init__(self, source, rpc_operation,
+                                       name_whitelist, target)
+
+    def is_error_visible(self):
+        return self._error_bar.get_visible()
+
+    def get_shown_domains(self):
+        model = self._rpc_combo_box.get_model()
+        model_iter = model.get_iter_first()
+        domains = []
+
+        while model_iter != None:
+            domain_name = model.get_value(model_iter, 1)
+
+            domains = domains + [domain_name]
+
+            model_iter = model.iter_next(model_iter)
+
+        return domains
+
+class RPCConfirmationWindowTestBase(MockRPCConfirmationWindow, GtkTestCase):
+    def __init__(self, test_method, source_name = "test-source",
+                 rpc_operation = "test.Operation",
+                 name_whitelist = VMListModelerMock.get_name_whitelist(),
+                 target_name = None):
+        GtkTestCase.__init__(self, test_method)
+        self.test_source_name = source_name
+        self.test_rpc_operation = rpc_operation
+        self.test_target_name = target_name
+
+        self._test_time = 0.1
+
+        self.test_called_close = False
+        self.test_called_show = False
+
+        self.test_clicked_ok = False
+        self.test_clicked_cancel = False
+
+        MockRPCConfirmationWindow.__init__(self,
+                                       self.test_source_name,
+                                       self.test_rpc_operation,
+                                       name_whitelist,
+                                       self.test_target_name,
+                                       focus_stealing_seconds = self._test_time)
+
+    def _can_perform_action(self):
+        return True
+
+    def _close(self):
+        self.test_called_close = True
+
+    def _show(self):
+        self.test_called_show = True
+
+    def _clicked_ok(self, button):
+        MockRPCConfirmationWindow._clicked_ok(self, button)
+        self.test_clicked_ok = True
+
+    def _clicked_cancel(self, button):
+        MockRPCConfirmationWindow._clicked_cancel(self, button)
+        self.test_clicked_cancel = True
+
+    def test_has_linked_the_fields(self):
+        self.assertIsNotNone(self._rpc_window)
+        self.assertIsNotNone(self._rpc_ok_button)
+        self.assertIsNotNone(self._rpc_cancel_button)
+        self.assertIsNotNone(self._rpc_label)
+        self.assertIsNotNone(self._source_entry)
+        self.assertIsNotNone(self._rpc_combo_box)
+        self.assertIsNotNone(self._error_bar)
+        self.assertIsNotNone(self._error_message)
+
+    def test_is_showing_source(self):
+        self.assertTrue(self.test_source_name in
+                            self._source_entry.get_text())
+
+    def test_is_showing_operation(self):
+        self.assertTrue(self.test_rpc_operation in
+                            self._rpc_label.get_text())
+
+    def test_escape_and_format_rpc_text(self):
+        self.assertEquals("qubes.<b>Test</b>",
+                          self._escape_and_format_rpc_text("qubes.Test"))
+        self.assertEquals("custom.<b>Domain</b>",
+                          self._escape_and_format_rpc_text("custom.Domain"))
+        self.assertEquals("<b>nodomain</b>",
+                          self._escape_and_format_rpc_text("nodomain"))
+        self.assertEquals("domain.<b>Sub.Operation</b>",
+                          self._escape_and_format_rpc_text("domain.Sub.Operation"))
+        self.assertEquals("<b></b>",
+                          self._escape_and_format_rpc_text(""))
+        self.assertEquals("<b>.</b>",
+                          self._escape_and_format_rpc_text("."))
+        self.assertEquals("inject.<b>&lt;script&gt;</b>",
+                          self._escape_and_format_rpc_text("inject.<script>"))
+        self.assertEquals("&lt;script&gt;.<b>inject</b>",
+                          self._escape_and_format_rpc_text("<script>.inject"))
+
+    def test_lifecycle_open_select_ok(self):
+        self._lifecycle_start(select_target = True)
+        self._lifecycle_click(click_type = "ok")
+
+    def test_lifecycle_open_select_cancel(self):
+        self._lifecycle_start(select_target = True)
+        self._lifecycle_click(click_type = "cancel")
+
+    def test_lifecycle_open_select_exit(self):
+        self._lifecycle_start(select_target = True)
+        self._lifecycle_click(click_type = "exit")
+
+    def test_lifecycle_open_cancel(self):
+        self._lifecycle_start(select_target = False)
+        self._lifecycle_click(click_type = "cancel")
+
+    def test_lifecycle_open_exit(self):
+        self._lifecycle_start(select_target = False)
+        self._lifecycle_click(click_type = "exit")
+
+    def _lifecycle_click(self, click_type):
+        if click_type == "ok":
+            self._rpc_ok_button.clicked()
+
+            self.assertTrue(self.test_clicked_ok)
+            self.assertFalse(self.test_clicked_cancel)
+            self.assertTrue(self._confirmed)
+            self.assertIsNotNone(self._target_qid)
+            self.assertIsNotNone(self._target_name)
+        elif click_type == "cancel":
+            self._rpc_cancel_button.clicked()
+
+            self.assertFalse(self.test_clicked_ok)
+            self.assertTrue(self.test_clicked_cancel)
+            self.assertFalse(self._confirmed)
+        elif click_type == "exit":
+            self._close()
+
+            self.assertFalse(self.test_clicked_ok)
+            self.assertFalse(self.test_clicked_cancel)
+            self.assertIsNone(self._confirmed)
+
+        self.assertTrue(self.test_called_close)
+
+
+    def _lifecycle_start(self, select_target):
+        self.assertFalse(self.test_called_close)
+        self.assertFalse(self.test_called_show)
+
+        self.assert_initial_state(False)
+        self.assertTrue(isinstance(self._focus_helper, FocusStealingHelperMock))
+
+        # Need the following because of pylint's complaints
+        if isinstance(self._focus_helper, FocusStealingHelperMock):
+            FocusStealingHelperMock.simulate_focus(self._focus_helper)
+
+        self.flush_gtk_events(self._test_time*2)
+        self.assert_initial_state(True)
+
+        try:
+            # We expect the call to exit immediately, since no window is opened
+            self.confirm_rpc()
+        except BaseException:
+            pass
+
+        self.assertFalse(self.test_called_close)
+        self.assertTrue(self.test_called_show)
+
+        self.assert_initial_state(True)
+
+        if select_target:
+            self._rpc_combo_box.set_active(1)
+
+            self.assertTrue(self._rpc_ok_button.get_sensitive())
+
+            self.assertIsNotNone(self._target_qid)
+            self.assertIsNotNone(self._target_name)
+
+        self.assertFalse(self.test_called_close)
+        self.assertTrue(self.test_called_show)
+        self.assertFalse(self.test_clicked_ok)
+        self.assertFalse(self.test_clicked_cancel)
+        self.assertFalse(self._confirmed)
+
+    def assert_initial_state(self, after_focus_timer):
+        self.assertIsNone(self._target_qid)
+        self.assertIsNone(self._target_name)
+        self.assertFalse(self.test_clicked_ok)
+        self.assertFalse(self.test_clicked_cancel)
+        self.assertFalse(self._confirmed)
+        self.assertFalse(self._rpc_ok_button.get_sensitive())
+        self.assertFalse(self._error_bar.get_visible())
+
+        if after_focus_timer:
+            self.assertTrue(self._focus_helper.can_perform_action())
+        else:
+            self.assertFalse(self._focus_helper.can_perform_action())
+
+class RPCConfirmationWindowTestWithTarget(RPCConfirmationWindowTestBase):
+    def __init__(self, test_method):
+        RPCConfirmationWindowTestBase.__init__(self, test_method,
+                 source_name = "test-source", rpc_operation = "test.Operation",
+                 target_name =  "test-target")
+
+    def test_lifecycle_open_ok(self):
+        self._lifecycle_start(select_target = False)
+        self._lifecycle_click(click_type = "ok")
+
+    def assert_initial_state(self, after_focus_timer):
+        self.assertIsNotNone(self._target_qid)
+        self.assertIsNotNone(self._target_name)
+        self.assertFalse(self.test_clicked_ok)
+        self.assertFalse(self.test_clicked_cancel)
+        self.assertFalse(self._confirmed)
+        if after_focus_timer:
+            self.assertTrue(self._rpc_ok_button.get_sensitive())
+            self.assertTrue(self._focus_helper.can_perform_action())
+        else:
+            self.assertFalse(self._rpc_ok_button.get_sensitive())
+            self.assertFalse(self._focus_helper.can_perform_action())
+
+    def _lifecycle_click(self, click_type):
+        RPCConfirmationWindowTestBase._lifecycle_click(self, click_type)
+        self.assertIsNotNone(self._target_qid)
+        self.assertIsNotNone(self._target_name)
+
+class RPCConfirmationWindowTestWithTargetInvalid(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        unittest.TestCase.__init__(self, *args, **kwargs)
+
+    def test_unknown(self):
+        self.assert_raises_error(True, "test-source", "test-wrong-target")
+
+    def test_empty(self):
+        self.assert_raises_error(True, "test-source", "")
+
+    def test_equals_source(self):
+        self.assert_raises_error(True, "test-source", "test-source")
+
+    def assert_raises_error(self, expect, source, target):
+        rpcWindow = MockRPCConfirmationWindow(source, "test.Operation",
+                                              target=target)
+        self.assertEquals(expect, rpcWindow.is_error_visible())
+
+class RPCConfirmationWindowTestWhitelist(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        unittest.TestCase.__init__(self, *args, **kwargs)
+
+    def test_no_domains(self):
+        self._assert_whitelist([],[])
+
+    def test_all_red_domains(self):
+        self._assert_whitelist(["test-red1", "test-red2", "test-red3"],
+                               ["test-red1", "test-red2", "test-red3"])
+
+    def test_all_red_domains_plus_nonexistent(self):
+        self._assert_whitelist(["test-red1", "test-red2", "test-red3",
+                                "test-blue1", "test-blue2", "test-blue3"],
+                               ["test-red1", "test-red2", "test-red3"])
+
+    def test_all_allowed_domains(self):
+        self._assert_whitelist(["test-red1", "test-red2", "test-red3",
+                                "test-target", "test-disp6", "test-source",
+                                "dom0"], ["test-red1", "test-red2", "test-red3",
+                                "test-target", "test-disp6", "test-source",
+                                "dom0"])
+
+    def _assert_whitelist(self, whitelist, expected):
+        rpcWindow = MockRPCConfirmationWindow("test-source", "test.Operation",
+                                              whitelist)
+
+        domains = rpcWindow.get_shown_domains()
+
+        for domain in expected:
+            self.assertTrue(domain in domains)
+
+        self.assertEquals(len(expected), len(domains))
+
+if __name__=='__main__':
+    test = False
+    window = False
+
+    if len(sys.argv) == 1 or sys.argv[1] == '-t':
+        test = True
+    elif sys.argv[1] == '-w':
+        window = True
+    else:
+        print "Usage: " + __file__ + " [-t|-w]"
+
+    if window:
+        print MockRPCConfirmationWindow("test-source",
+                                        "qubes.Filecopy",
+                                        VMListModelerMock.get_name_whitelist(),
+                                        "test-red1").confirm_rpc()
+    elif test:
+        unittest.main(argv = [sys.argv[0]])

+ 56 - 0
qubespolicy/utils.py

@@ -0,0 +1,56 @@
+# -*- encoding: utf8 -*-
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2017 boring-stuff <boring-stuff@users.noreply.github.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, see <http://www.gnu.org/licenses/>.
+
+
+def _sanitize_char(input_char, extra_allowed_characters):
+    input_char_ord = ord(input_char)
+
+    if (input_char_ord >= ord('a') and input_char_ord <= ord('z')) \
+       or (input_char_ord >= ord('A') and input_char_ord <= ord('Z')) \
+       or (input_char_ord >= ord('0') and input_char_ord <= ord('9')) \
+       or (input_char in ['$', '_', '-', '.']) \
+       or (extra_allowed_characters != None
+                and input_char in extra_allowed_characters):
+        result = input_char
+    else:
+        result = '_'
+
+    return result
+
+# This function needs to be synchronized with qrexec-daemon.c's sanitize_name()
+# from the qubes-core-admin-linux repository.
+#
+# See https://github.com/QubesOS/qubes-core-admin-linux/blob/4f0878ccbf8a95f8264b54d2b6f4dc433ca0793a/qrexec/qrexec-daemon.c#L627-L646
+#
+def _sanitize_name(input_string, extra_allowed_characters, assert_sanitized):
+    result = ''.join(_sanitize_char(character, extra_allowed_characters) \
+                    for character in input_string)
+
+    if assert_sanitized:
+        assert input_string == result, \
+               'Input string was expected to be sanitized, but was not.'
+    else:
+        return result
+
+def sanitize_domain_name(input_string, assert_sanitized = False):
+    return _sanitize_name(input_string, None, assert_sanitized)
+
+def sanitize_service_name(input_string, assert_sanitized = False):
+    return _sanitize_name(input_string, {'+'}, assert_sanitized)
+