source: trunk/debathena/debathena/nautilus-afs/afs-property-page.py @ 25960

Revision 25960, 19.3 KB checked in by jdreed, 11 years ago (diff)
In nautilus-afs: * Fix bug which didn't reset "add" dialog after cancel (Trac: #1339) * Switch to dh7 from CDBS * Bump Standards-Version to 3.9.3 and compat to 7 * Replace completely wrong copyright file with copyright format 1.0
RevLine 
[24445]1import urllib
[25763]2import sys
[24445]3import os
[25763]4import subprocess
5import errno
6import textwrap
7import pwd
[24445]8import re
[25763]9from gi.repository import Nautilus, Gtk, GObject, GLib, Gdk
[24445]10
[25763]11UI_FILE="/usr/share/debathena-nautilus-afs/afs-property-page.ui"
12# Paths that are likely in AFS.  This should be an inclusive list, and
13# we check for EINVAL when initially fetching the ACL.
14AFS_PATHS=("/afs", "/mit")
15# Valid rights for site-specific permissions
16VALID_SITE_RIGHTS="ABCDEFGH"
[24445]17
[25763]18class AFSAclException(OSError):
19    def __init__(self, errnoOrMsg, message=None):
20        if message is None:
21            message = errnoOrMsg
22            errnoOrMsg = None
23        OSError.__init__(self, errnoOrMsg, message)
24        self.message = message
[24445]25
[25763]26class AFSAcl:
27    # Escape the ampersand because these are tooltips and go through Pango
28    # and maybe everything in Gtk3 does?
29    _specialEntities = { "system:anyuser": "Any anonymous user or web browser",
30                         "system:authuser": "Any MIT user",
31                         "system:expunge": "The IS&T automated expunger",
32                         "system:administrators": "An IS&T AFS administrator"}
33   
34    _englishRights = { "rlidwka": "all permissions",
35                       "rlidwk": "write permissions",
36                       "rl": "read permissions"}
37
38    def __init__(self, path):
39        if not os.path.exists(path):
40            raise AFSAclException(errno.ENOENT, "That path does not exist")
41        self.path = path
42        self._loadAcl()
43       
44    def _loadAcl(self):
45        fsla = subprocess.Popen(["fs", "listacl", "-path", self.path],
46                                stdout=subprocess.PIPE,
47                                stderr=subprocess.PIPE)
48        (out, err) = fsla.communicate()
49        if fsla.returncode != 0:
50            if err.startswith("fs: Invalid argument"):
51                raise AFSAclException(errno.EINVAL, err.strip())
52            elif err.startswith("fs: You don't have the required access rights"):
53                raise AFSAclException(errno.EACCES, err.strip())
54            else:
55                raise AFSAclException(err.strip())
56        else:
57            self._parseACL(out)
58   
59    def _parseACL(self, fsla):
60        self.pos = {}
61        self.neg = {}
62        lines = fsla.splitlines()
63        # If a directory has no normal rights, we have no idea what's going on
64        if "Normal rights:" not in lines:
65            raise AFSAclException("No normal rights found while parsing?")
66        posidx = lines.index("Normal rights:")
67        try:
68            negidx = lines.index("Negative rights:")
69        except ValueError:
70            negidx = None
71        if negidx is None:
72            self.pos = self._parseEntries(lines[posidx+1:])
73        else:
74            self.pos = self._parseEntries(lines[posidx+1:negidx])
75            self.neg = self._parseEntries(lines[negidx+1:])
76
77    def _parseEntries(self, entList):
78        rv = {}
79        for i in entList:
80            (name, acl) = i.strip().split()
81            rv[name] = acl
82        return rv
83
84    def _setacl(self, entity, rights, negative=False):
85        cmdlist = ["fs", "setacl", "-dir", self.path, "-acl", entity, rights]
86        if negative:
87            cmdlist.append("-negative")
88        fsla = subprocess.Popen(cmdlist,
89                                stdout=subprocess.PIPE,
90                                stderr=subprocess.PIPE)
91        (out, err) = fsla.communicate()
92        if fsla.returncode != 0:
93            if err.startswith("fs: Invalid argument"):
94                raise AFSAclException(errno.EINVAL, err.strip())
95            elif err.startswith("fs: You don't have the required access rights"):
96                raise AFSAclException(errno.EACCES, err.strip())
97            elif err.startswith("fs: You can not change a backup or readonly volume"):
98                raise AFSAclException(errno.EROFS, err.strip())
99            else:
100                raise AFSAclException(err.strip())
101   
102    @classmethod
103    def isDeactivatedUser(cls, ent):
104        if not ent.startswith('-'):
105            return False
106        try:
107            uid = int(ent)
108            return (uid < 0)
109        except ValueError:
110            pass
111        return False
112
113    @classmethod
114    def entityToEnglish(cls, ent):
115        if ent in cls._specialEntities:
116            return cls._specialEntities[ent]
117        if ent.startswith('system:'):
118            return "The Moira group " + ent
119        try:
120            pwent = pwd.getpwnam(ent)
121            return "%s (%s)" % (pwent[4].split(',')[0], ent)
122        except KeyError:
123            pass
124        return "The %suser '%s'" % ("deactivated " if cls.isDeactivatedUser(ent) else "", ent)
125
126    @classmethod
127    def rightsToEnglish(cls, rightString):
128        # TODO: str.format() or Formatter
129        rights=rightString
130        site=re.sub(r'[rlidwka]', '', rights)
131        if site:
132            rights=rightString.replace(site, '')
133        english=""
134        for right in sorted(cls._englishRights, None, None, True):
135            if right in rights:
136                english += cls._englishRights[right]
137                break
138        if re.search(r'[idwka]', rights):
139            if english and right == "rl":
140                english += " and '%s' permissions" % (rights)
141        if not english:
142            english += "'%s' permissions" % (rights)
143        if rights == "l":
144            english = "permission to list, but not read, files and directories"
145        if site:
146            english += "\nas well as site-specific permission(s) '%s'" % (site)
147        return english
148
149
150
[24445]151class AFSPermissionsPane():
152    def __init__(self, fp):
153        self.filepath = fp
154        self.acl = None
[25763]155        self.rootWidget = None
156        self.inAFS = False
157        for item in AFS_PATHS:
158            if self.filepath.startswith(item):
159                self.inAFS=True
160        self.builder = Gtk.Builder()
161        ui = os.getenv('DA_AFS_PROPERTY_PAGE_UI')
162        if not ui:
163            ui = UI_FILE
164        try:
165            self.builder.add_from_file(ui)
166        except GLib.GError as e:
167            self.rootWidget = self._errorBox("Could not load the AFS user interface.", e.message)
[24445]168            return
[25763]169
[24445]170        try:
[25763]171            self.acl = AFSAcl(self.filepath)
172        except OSError as e:
173            if e.errno == errno.EACCES:
174                self.rootWidget = self.builder.get_object("vboxNoPerms")
175                return
176            elif e.errno == errno.EINVAL:
[24445]177                self.inAFS = False
[25763]178                return
179            else:
180                self.rootWidget = self._errorBox("Unexpected error!", str(e))
181                return
182
183        self.rootWidget = self.builder.get_object("vboxMain")
184        handlers = {
185            "add_clicked_cb": self.addEntry,
186            "edit_clicked_cb": self.editEntry,
187            "remove_clicked_cb": self.removeEntry,
188            "access_combo_changed_cb": self.dlgAccessChanged,
189            "entity_combo_changed_cb": self.dlgEntityChanged,
190            "entity_text_changed_cb": self.dlgEntityTextChanged,
191            "group_checkbox_toggled_cb": self.groupToggled,
192            "aclview_row_activated_cb": self.editEntry,
193            "siteperms_entity_insert_text_cb": self.validateSitePerms,
194        }
195        self.builder.connect_signals(handlers)
196        self.addDlg = self.builder.get_object("aclDialog")
197        self._refreshAclUI()
[24445]198       
[25763]199    # Used to fail gracefully when we can't find the UI.
200    def _errorBox(self, msg, longMsg=None):
201        vbox = Gtk.VBox(homogeneous=False, spacing=0)
202        vbox.show()
203        msgTxt = msg
204        if longMsg is not None:
205            msgTxt += "\n\nError details:\n"
206            msgTxt += textwrap.fill(longMsg, 60)
207        label = Gtk.Label(msgTxt)
208        label.show()
209        vbox.pack_start(label, True, True, 0)
210        return vbox
211   
212    # Reload the UI based on the ACL.
213    def _refreshAclUI(self):
214        self.builder.get_object("lblPath").set_text(self.filepath)
215        self.builder.get_object("lblPath").set_tooltip_text(self.filepath)
216        self.store = self.builder.get_object("aclListStore")
217        self.store.clear()
218        if self.acl is not None:
219            for i in self.acl.pos.items():
220                self.store.append(i + (False, AFSAcl.entityToEnglish(i[0]) + " has " + AFSAcl.rightsToEnglish(i[1]), i[1], 'gtk-yes', Gtk.IconSize.MENU))
221            for i in self.acl.neg.items():
222                self.store.append(i + (True, AFSAcl.entityToEnglish(i[0]) + " does <b>not</b> have " + AFSAcl.rightsToEnglish(i[1]), i[1] + "  <i>(negative rights)</i>",'gtk-no', Gtk.IconSize.MENU))
[24445]223
[25763]224    # Reload the ACL.  Note that the initial loading happens in the
225    # constructor, because if there are no permissions, we just want
226    # display a vbox, not a dialog (which is obnoxious from a UI
227    # perspective
228    def _reloadAcl(self):
229        self.acl = None
230        try:
231            self.acl = AFSAcl(self.filepath)
232        except OSError as e:
233            if e.errno == errno.EACCES:
234                self._errDialog("You no longer have permissions to view the ACL for this diectory.")
235            else:
236                self._errDialog("Unexpected error!", "Full error text:\n" + str(e))
237            # Disable the UI, we can't continue at this point
238            self.rootWidget.set_sensitive(False)
239        # And refresh or clear the UI
240        self._refreshAclUI()
[24445]241
[25763]242    # Get the currently selected entry
243    def _getSelectedEntry(self):
244        tree = self.builder.get_object("tvACL")
245        model, it = tree.get_selection().get_selected()
246        if it != None:
247            return model[it]
[24445]248        else:
[25763]249            return None
250   
251    # Apply and ACL and deal with the UI accordingly
252    def _setacl(self, entity, rights, negative=False):
253        try:
254            self.acl._setacl(entity, rights, negative)
255        except OSError as e:
256            if e.errno == errno.EACCES:
257                self._errDialog("You don't have permissions to change the ACL on this directory.")
258            elif e.errno == errno.EINVAL:
259                self._errDialog("Error: %s\n\n(This is typically caused by specifying a user or group that doesn't exist.)" % (e.message))
260            elif e.errno == errno.EROFS:
261                self._errDialog("This is a read-only filesystem and cannot be changed.","(Hint: If you're trying to change your OldFiles directory,\nyou can't, because it's a nightly snapshot.)")
262            else:
263                self._errDialog("Unexpected error!", e.message)
264        self._reloadAcl()
[24445]265
[25763]266    # Set the "access" combobox from an ACL
267    def _setAccessComboFromACL(self, acl):
268        for row in self.builder.get_object("accessCombo").get_model():
269            if row[0] == acl:
270                self.builder.get_object("accessCombo").set_active_iter(row.iter)
271                break
[24445]272
[25763]273    # Prepare the ACL dialog based on what we want to do
274    def _prepareAclDialog(self, editMode=False, entity=None, rights=None, negative=False):
275        self.addDlg.set_transient_for(self._getParentWindow())
276        self.addDlg.set_title("Change permissions for '%s'" % (entity) if editMode else "Add an entry")
[25960]277        # Set entity combo to "specify manually"
278        # This would be easier if GtkBuilder actually set the id-column
279        # property.  Fortunately, it's the first entry in the list.
280        self.builder.get_object("entityCombo").set_active_iter(self.builder.get_object("entityCombo").get_model().get_iter_first())
[25763]281        # Set the "OK" button to unsensitive until something is in the text field
282        self.builder.get_object("aclDlgOK").set_sensitive(False)
283        # Clear the text field
284        self.builder.get_object("entityText").set_text("")
285        # Default to "specify manually" for the ACL:
286        self._setAccessComboFromACL("-" if editMode else "rl")
287        # No need to call dlgAccessChanged here, because set_active_iter will
288        # emit the signal.  .set-active() with a row number will not.
289        self.builder.get_object("accessNegative").set_active(negative)
290        sitePerms = ""
291        if rights:
292            sitePerms = re.sub(r'[rlidwka]', '', rights)
293        self.builder.get_object("sitePerms").set_text(sitePerms)
294        self.builder.get_object("sitePermsExpander").set_expanded(sitePerms != "")
295        self.builder.get_object("entityCombo").set_sensitive(not editMode)
296        self.builder.get_object("entityText").set_sensitive(not editMode)
297        self.builder.get_object("entityIsGroup").set_sensitive(not editMode)
298        self.builder.get_object("negPermsExpander").set_expanded(negative)
299        # We don't support turning negative rights to positive ones
300        self.builder.get_object("accessNegative").set_sensitive(not editMode)
301        if editMode:
302            self.dlgSetRightsFromString(rights, True)
303            self.builder.get_object("entityText").set_text(entity)
[24445]304
[25763]305    # Turn the dialog's state back into somthing that can be applied
306    def _applyRightsFromDialog(self):
307            entity = self.builder.get_object("entityText").get_text().strip()
308            rights=""
309            for widget in self.builder.get_object("rightsBox").get_children():
310                rights += widget.get_label() if widget.get_active() else ""
311            rights += self.builder.get_object("sitePerms").get_text().strip()
312            self._setacl(entity, rights, self.builder.get_object("accessNegative").get_active())
313       
314    # "Add" button callback
315    def addEntry(self, widget):
316        self._prepareAclDialog()
317        if self.addDlg.run() == Gtk.ResponseType.OK:
318            self._applyRightsFromDialog()
319        self.addDlg.hide()
[24445]320
[25763]321    # "Edit" button callback
322    def editEntry(self, widget, row=None, treeCol=None):
323        row = self._getSelectedEntry()
324        if row is None:
[24445]325            return
[25763]326        self._prepareAclDialog(True, row[0], row[1], row[2])
327        if self.addDlg.run() == Gtk.ResponseType.OK:
328            self._applyRightsFromDialog()
329        self.addDlg.hide()
[24445]330
[25763]331    # "Remove" button callback
332    def removeEntry(self, widget):
333        row = self._getSelectedEntry()
334        if row is not None:
335            if row[0] == os.getenv("USER"):
336                if not self._confirmDialog("Are you sure you want to remove yourself from the ACL?"):
337                    return
338            self._setacl(row[0], "none", row[2])
[24445]339
[25763]340    # Set the "rights" checkboxes from an ACL string.
341    # and whether they should be enabled or disabled
342    def dlgSetRightsFromString(self, rightString, enable):
343        for widget in self.builder.get_object("rightsBox").get_children():
344            widget.set_sensitive(enable)
345            if widget.get_label() in rightString:
346                widget.set_active(True)
347            else:
348                widget.set_active(False)
[24445]349
[25763]350    # callback for "changed" signal on combobox
351    # Update the checkboxes to match the combobox
352    def dlgAccessChanged(self, widget):
353        iter = widget.get_active_iter()
354        if iter is not None:
355            bits=widget.get_model()[iter][0]
356            self.dlgSetRightsFromString(bits, bits == "-")
[24445]357
[25763]358    # Callback for the "toggled" signal on the "Is a group" checkbox
359    def groupToggled(self, widget):
360        ent = self.builder.get_object("entityText").get_text()
361        if widget.get_active():
362            if not ent.startswith("system:"):
363                self.builder.get_object("entityText").set_text("system:" + ent)
364        else:
365            self.builder.get_object("entityText").set_text(re.sub(r'^system:', '', ent))
[24445]366
[25763]367    # Callback for GtkEditable's "insert-text" signal on the "site permissions entry
368    # If it's a zero-length text insertion, then it's "valid"
369    # If it's a signal character, ensure it's in the valid set
370    # For anything else, display an excalmation point in the box and
371    # stop signal emission
372    def validateSitePerms(self, widget, text, text_len, ptr):
373        if text_len == 0:
374            return True
375        if text_len == 1:
376            if text in VALID_SITE_RIGHTS:
377                widget.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, None)
378                return True
379        widget.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, 'gtk-dialog-warning')
380        widget.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, False)
381        widget.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, "Only the characters '%s' are allowed" % (VALID_SITE_RIGHTS))
382        widget.stop_emission('insert-text')
[24445]383
[25763]384    # Callback for the "changed" signal on the "entity" Entry.
385    def dlgEntityTextChanged(self, widget):
386        self.builder.get_object("entityIsGroup").set_active(widget.get_text().startswith("system:"))
387        self.builder.get_object("aclDlgOK").set_sensitive(widget.get_text().strip() != "")
[24445]388
[25763]389    # Callback for the "entity" combobox
390    def dlgEntityChanged(self, widget):
391        iter = widget.get_active_iter()
392        if iter is not None:
393            name=widget.get_model()[iter][0]
394            if name == "-":
395                self.builder.get_object("entityText").set_sensitive(True)
396                self.builder.get_object("entityIsGroup").set_sensitive(True)
397            else:
398                self.builder.get_object("entityText").set_sensitive(False)
399                self.builder.get_object("entityText").set_text(name)
400                self.builder.get_object("entityIsGroup").set_sensitive(False)
[24445]401
[25763]402    # Convenience function to get the parent window, since we don't have
403    # access to it directly.
404    def _getParentWindow(self):
405        # Probably not the best idea
406        parent = self.rootWidget.get_parent()
407        while parent is not None:
408            if isinstance(parent, Gtk.Window):
409                break
410            parent = parent.get_parent()
411        return parent
[24445]412
413
[25763]414    # Convenience functions
415    def _errDialog(self, message, secondaryMsg=None):
416        dlg = Gtk.MessageDialog(self._getParentWindow(),
417                                Gtk.DialogFlags.DESTROY_WITH_PARENT,
418                                Gtk.MessageType.ERROR,
419                                Gtk.ButtonsType.CLOSE,
420                                message)
421        dlg.set_title("Error")
422        if secondaryMsg:
423            dlg.format_secondary_text(secondaryMsg)
424        dlg.run()
425        dlg.destroy()
[24445]426
[25763]427    def _confirmDialog(self, message, secondaryMsg=None):
428        dlg = Gtk.MessageDialog(self._getParentWindow(),
429                                Gtk.DialogFlags.DESTROY_WITH_PARENT,
430                                Gtk.MessageType.QUESTION,
431                                Gtk.ButtonsType.YES_NO,
432                                message)
433        dlg.set_title("Confirm")
434        if secondaryMsg:
435            dlg.format_secondary_text(secondaryMsg)
436        rval = dlg.run()
437        dlg.destroy()
438        return (rval == Gtk.ResponseType.YES)
[24445]439
440
[25763]441class AFSPropertyPage(GObject.GObject, Nautilus.PropertyPageProvider):
[24445]442    def __init__(self):
[25763]443        pass
444   
[24445]445    def get_property_pages(self, files):
[25763]446        # Not supported for multiple selections
[24445]447        if len(files) != 1:
448            return
449       
450        file = files[0]
[25763]451        # Not supported for other URIs
[24445]452        if file.get_uri_scheme() != 'file':
453            return
454
455        # Only works on directories
[25763]456        # TODO: symlinks?
[24445]457        if not file.is_directory():
458            return
459
[25763]460        # Should probably use urlparse, but meh
461        filepath = urllib.unquote(file.get_uri()[7:])
[24445]462
[25763]463        self.property_label = Gtk.Label('AFS Permissions')
464        self.property_label.show()
465
[24445]466        pane = AFSPermissionsPane(filepath)
467
[25763]468        if not pane.inAFS or pane.rootWidget is None:
[24445]469            return
470
[25763]471        return Nautilus.PropertyPage(name="NautilusPython::afs",
472                                     label=self.property_label,
473                                     page=pane.rootWidget),
Note: See TracBrowser for help on using the repository browser.