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
Line 
1import urllib
2import sys
3import os
4import subprocess
5import errno
6import textwrap
7import pwd
8import re
9from gi.repository import Nautilus, Gtk, GObject, GLib, Gdk
10
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"
17
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
25
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
151class AFSPermissionsPane():
152    def __init__(self, fp):
153        self.filepath = fp
154        self.acl = None
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)
168            return
169
170        try:
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:
177                self.inAFS = False
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()
198       
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))
223
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()
241
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]
248        else:
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()
265
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
272
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")
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())
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)
304
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()
320
321    # "Edit" button callback
322    def editEntry(self, widget, row=None, treeCol=None):
323        row = self._getSelectedEntry()
324        if row is None:
325            return
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()
330
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])
339
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)
349
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 == "-")
357
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))
366
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')
383
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() != "")
388
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)
401
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
412
413
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()
426
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)
439
440
441class AFSPropertyPage(GObject.GObject, Nautilus.PropertyPageProvider):
442    def __init__(self):
443        pass
444   
445    def get_property_pages(self, files):
446        # Not supported for multiple selections
447        if len(files) != 1:
448            return
449       
450        file = files[0]
451        # Not supported for other URIs
452        if file.get_uri_scheme() != 'file':
453            return
454
455        # Only works on directories
456        # TODO: symlinks?
457        if not file.is_directory():
458            return
459
460        # Should probably use urlparse, but meh
461        filepath = urllib.unquote(file.get_uri()[7:])
462
463        self.property_label = Gtk.Label('AFS Permissions')
464        self.property_label.show()
465
466        pane = AFSPermissionsPane(filepath)
467
468        if not pane.inAFS or pane.rootWidget is None:
469            return
470
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.