xref: /illumos-gate/usr/src/tools/scripts/validate_pkg.py (revision 4f364e7c95ee7fd9d5bbeddc1940e92405bb0e72)
1#!/usr/bin/python2.6
2#
3# CDDL HEADER START
4#
5# The contents of this file are subject to the terms of the
6# Common Development and Distribution License (the "License").
7# You may not use this file except in compliance with the License.
8#
9# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE
10# or http://www.opensolaris.org/os/licensing.
11# See the License for the specific language governing permissions
12# and limitations under the License.
13#
14# When distributing Covered Code, include this CDDL HEADER in each
15# file and include the License file at usr/src/OPENSOLARIS.LICENSE.
16# If applicable, add the following below this CDDL HEADER, with the
17# fields enclosed by brackets "[]" replaced with your own identifying
18# information: Portions Copyright [yyyy] [name of copyright owner]
19#
20# CDDL HEADER END
21#
22
23#
24# Copyright 2010 Sun Microsystems, Inc.  All rights reserved.
25# Use is subject to license terms.
26#
27
28#
29# Compare the content generated by a build to a set of manifests
30# describing how that content is to be delivered.
31#
32
33
34import getopt
35import os
36import stat
37import sys
38
39from pkg import actions
40from pkg import manifest
41
42
43#
44# Dictionary used to map action names to output format.  Each entry is
45# indexed by action name, and consists of a list of tuples that map
46# FileInfo class members to output labels.
47#
48OUTPUTMAP = {
49    "dir": [
50        ("group", "group="),
51        ("mode", "mode="),
52        ("owner", "owner="),
53        ("path", "path=")
54    ],
55    "file": [
56        ("hash", ""),
57        ("group", "group="),
58        ("mode", "mode="),
59        ("owner", "owner="),
60        ("path", "path=")
61    ],
62    "link": [
63        ("path", "path="),
64        ("target", "target=")
65    ],
66    "hardlink": [
67        ("path", "path="),
68        ("hardkey", "target=")
69    ],
70}
71
72# Mode checks used to validate safe file and directory permissions
73ALLMODECHECKS = frozenset(("m", "w", "s", "o"))
74DEFAULTMODECHECKS = frozenset(("m", "w", "o"))
75
76class FileInfo(object):
77    """Base class to represent a file.
78
79    Subclassed according to whether the file represents an actual filesystem
80    object (RealFileInfo) or an IPS manifest action (ActionInfo).
81    """
82
83    def __init__(self):
84        self.path = None
85        self.isdir = False
86        self.target = None
87        self.owner = None
88        self.group = None
89        self.mode = None
90        self.hardkey = None
91        self.hardpaths = set()
92        self.editable = False
93
94    def name(self):
95        """Return the IPS action name of a FileInfo object.
96        """
97        if self.isdir:
98            return "dir"
99
100        if self.target:
101            return "link"
102
103        if self.hardkey:
104            return "hardlink"
105
106        return "file"
107
108    def checkmodes(self, modechecks):
109        """Check for and report on unsafe permissions.
110
111        Returns a potentially empty list of warning strings.
112        """
113        w = []
114
115        t = self.name()
116        if t in ("link", "hardlink"):
117            return w
118        m = int(self.mode, 8)
119        o = self.owner
120        p = self.path
121
122        if "s" in modechecks and t == "file":
123            if m & (stat.S_ISUID | stat.S_ISGID):
124                if m & (stat.S_IRGRP | stat.S_IROTH):
125                    w.extend(["%s: 0%o: setuid/setgid file should not be " \
126                        "readable by group or other" % (p, m)])
127
128        if "o" in modechecks and o != "root" and ((m & stat.S_ISUID) == 0):
129            mu = (m & stat.S_IRWXU) >> 6
130            mg = (m & stat.S_IRWXG) >> 3
131            mo = m & stat.S_IRWXO
132            e = self.editable
133
134            if (((mu & 02) == 0 and (mo & mg & 04) == 04) or
135                (t == "file" and mo & 01 == 1) or
136                (mg, mo) == (mu, mu) or
137                ((t == "file" and not e or t == "dir" and o == "bin") and
138                (mg & 05 == mo & 05)) or
139                (t == "file" and o == "bin" and mu & 01 == 01) or
140                (m & 0105 != 0 and p.startswith("etc/security/dev/"))):
141                w.extend(["%s: owner \"%s\" may be safely " \
142                    "changed to \"root\"" % (p, o)])
143
144        if "w" in modechecks and t == "file" and o != "root":
145            uwx = stat.S_IWUSR | stat.S_IXUSR
146            if m & uwx == uwx:
147                w.extend(["%s: non-root-owned executable should not " \
148                    "also be writable by owner." % p])
149
150        if ("m" in modechecks and
151            m & (stat.S_IWGRP | stat.S_IWOTH) != 0 and
152            m & stat.S_ISVTX == 0):
153            w.extend(["%s: 0%o: should not be writable by group or other" %
154                (p, m)])
155
156        return w
157
158    def __ne__(self, other):
159        """Compare two FileInfo objects.
160
161        Note this is the "not equal" comparison, so a return value of False
162        indicates that the objects are functionally equivalent.
163        """
164        #
165        # Map the objects such that the lhs is always the ActionInfo,
166        # and the rhs is always the RealFileInfo.
167        #
168        # It's only really important that the rhs not be an
169        # ActionInfo; if we're comparing FileInfo the RealFileInfo, it
170        # won't actually matter what we choose.
171        #
172        if isinstance(self, ActionInfo):
173            lhs = self
174            rhs = other
175        else:
176            lhs = other
177            rhs = self
178
179        #
180        # Because the manifest may legitimately translate a relative
181        # path from the proto area into a different path on the installed
182        # system, we don't compare paths here.  We only expect this comparison
183        # to be invoked on items with identical relative paths in
184        # first place.
185        #
186
187        #
188        # All comparisons depend on type.  For symlink and directory, they
189        # must be the same.  For file and hardlink, see below.
190        #
191        typelhs = lhs.name()
192        typerhs = rhs.name()
193        if typelhs in ("link", "dir"):
194            if typelhs != typerhs:
195                return True
196
197        #
198        # For symlinks, all that's left is the link target.
199        #
200        if typelhs == "link":
201            return lhs.target != rhs.target
202
203        #
204        # For a directory, it's important that both be directories,
205        # the modes be identical, and the paths are identical.  We already
206        # checked all but the modes above.
207        #
208        # If both objects are files, then we're in the same boat.
209        #
210        if typelhs == "dir" or (typelhs == "file" and typerhs == "file"):
211            return lhs.mode != rhs.mode
212
213        #
214        # For files or hardlinks:
215        #
216        # Since the key space is different (inodes for real files and
217        # actual link targets for hard links), and since the proto area will
218        # identify all N occurrences as hardlinks, but the manifests as one
219        # file and N-1 hardlinks, we have to compare files to hardlinks.
220        #
221
222        #
223        # If they're both hardlinks, we just make sure that
224        # the same target path appears in both sets of
225        # possible targets.
226        #
227        if typelhs == "hardlink" and typerhs == "hardlink":
228            return len(lhs.hardpaths.intersection(rhs.hardpaths)) == 0
229
230        #
231        # Otherwise, we have a mix of file and hardlink, so we
232        # need to make sure that the file path appears in the
233        # set of possible target paths for the hardlink.
234        #
235        # We already know that the ActionInfo, if present, is the lhs
236        # operator.  So it's the rhs operator that's guaranteed to
237        # have a set of hardpaths.
238        #
239        return lhs.path not in rhs.hardpaths
240
241    def __str__(self):
242        """Return an action-style representation of a FileInfo object.
243
244        We don't currently quote items with embedded spaces.  If we
245        ever decide to parse this output, we'll want to revisit that.
246        """
247        name = self.name()
248        out = name
249
250        for member, label in OUTPUTMAP[name]:
251            out += " " + label + str(getattr(self, member))
252
253        return out
254
255    def protostr(self):
256        """Return a protolist-style representation of a FileInfo object.
257        """
258        target = "-"
259        major = "-"
260        minor = "-"
261
262        mode = self.mode
263        owner = self.owner
264        group = self.group
265
266        name = self.name()
267        if name == "dir":
268            ftype = "d"
269        elif name in ("file", "hardlink"):
270            ftype = "f"
271        elif name == "link":
272            ftype = "s"
273            target = self.target
274            mode = "777"
275            owner = "root"
276            group = "other"
277
278        out = "%c %-30s %-20s %4s %-5s %-5s %6d %2ld  -  -" % \
279            (ftype, self.path, target, mode, owner, group, 0, 1)
280
281        return out
282
283
284class ActionInfo(FileInfo):
285    """Object to track information about manifest actions.
286
287    This currently understands file, link, dir, and hardlink actions.
288    """
289
290    def __init__(self, action):
291        FileInfo.__init__(self)
292        #
293        # Currently, all actions that we support have a "path"
294        # attribute.  If that changes, then we'll need to
295        # catch a KeyError from this assignment.
296        #
297        self.path = action.attrs["path"]
298
299        if action.name == "file":
300            self.owner = action.attrs["owner"]
301            self.group = action.attrs["group"]
302            self.mode = action.attrs["mode"]
303            self.hash = action.hash
304            if "preserve" in action.attrs:
305                self.editable = True
306        elif action.name == "link":
307            target = action.attrs["target"]
308            self.target = os.path.normpath(target)
309        elif action.name == "dir":
310            self.owner = action.attrs["owner"]
311            self.group = action.attrs["group"]
312            self.mode = action.attrs["mode"]
313            self.isdir = True
314        elif action.name == "hardlink":
315            target = os.path.normpath(action.get_target_path())
316            self.hardkey = target
317            self.hardpaths.add(target)
318
319    @staticmethod
320    def supported(action):
321        """Indicates whether the specified IPS action time is
322        correctly handled by the ActionInfo constructor.
323        """
324        return action in frozenset(("file", "dir", "link", "hardlink"))
325
326
327class UnsupportedFileFormatError(Exception):
328    """This means that the stat.S_IFMT returned something we don't
329    support, ie a pipe or socket.  If it's appropriate for such an
330    object to be in the proto area, then the RealFileInfo constructor
331    will need to evolve to support it, or it will need to be in the
332    exception list.
333    """
334    def __init__(self, path, mode):
335        Exception.__init__(self)
336        self.path = path
337        self.mode = mode
338
339    def __str__(self):
340        return '%s: unsupported S_IFMT %07o' % (self.path, self.mode)
341
342
343class RealFileInfo(FileInfo):
344    """Object to track important-to-packaging file information.
345
346    This currently handles regular files, directories, and symbolic links.
347
348    For multiple RealFileInfo objects with identical hardkeys, there
349    is no way to determine which of the hard links should be
350    delivered as a file, and which as hardlinks.
351    """
352
353    def __init__(self, root=None, path=None):
354        FileInfo.__init__(self)
355        self.path = path
356        path = os.path.join(root, path)
357        lstat = os.lstat(path)
358        mode = lstat.st_mode
359
360        #
361        # Per stat.py, these cases are mutually exclusive.
362        #
363        if stat.S_ISREG(mode):
364            self.hash = self.path
365        elif stat.S_ISDIR(mode):
366            self.isdir = True
367        elif stat.S_ISLNK(mode):
368            self.target = os.path.normpath(os.readlink(path))
369        else:
370            raise UnsupportedFileFormatError(path, mode)
371
372        if not stat.S_ISLNK(mode):
373            self.mode = "%04o" % stat.S_IMODE(mode)
374            #
375            # Instead of reading the group and owner from the proto area after
376            # a non-root build, just drop in dummy values.  Since we don't
377            # compare them anywhere, this should allow at least marginally
378            # useful comparisons of protolist-style output.
379            #
380            self.owner = "owner"
381            self.group = "group"
382
383        #
384        # refcount > 1 indicates a hard link
385        #
386        if lstat.st_nlink > 1:
387            #
388            # This could get ugly if multiple proto areas reside
389            # on different filesystems.
390            #
391            self.hardkey = lstat.st_ino
392
393
394class DirectoryTree(dict):
395    """Meant to be subclassed according to population method.
396    """
397    def __init__(self, name):
398        dict.__init__(self)
399        self.name = name
400
401    def compare(self, other):
402        """Compare two different sets of FileInfo objects.
403        """
404        keys1 = frozenset(self.keys())
405        keys2 = frozenset(other.keys())
406
407        common = keys1.intersection(keys2)
408        onlykeys1 = keys1.difference(common)
409        onlykeys2 = keys2.difference(common)
410
411        if onlykeys1:
412            print "Entries present in %s but not %s:" % \
413                (self.name, other.name)
414            for path in sorted(onlykeys1):
415                print("\t%s" % str(self[path]))
416            print ""
417
418        if onlykeys2:
419            print "Entries present in %s but not %s:" % \
420                (other.name, self.name)
421            for path in sorted(onlykeys2):
422                print("\t%s" % str(other[path]))
423            print ""
424
425        nodifferences = True
426        for path in sorted(common):
427            if self[path] != other[path]:
428                if nodifferences:
429                    nodifferences = False
430                    print "Entries that differ between %s and %s:" \
431                        % (self.name, other.name)
432                print("%14s %s" % (self.name, self[path]))
433                print("%14s %s" % (other.name, other[path]))
434        if not nodifferences:
435            print ""
436
437
438class BadProtolistFormat(Exception):
439    """This means that the user supplied a file via -l, but at least
440    one line from that file doesn't have the right number of fields to
441    parse as protolist output.
442    """
443    def __str__(self):
444        return 'bad proto list entry: "%s"' % Exception.__str__(self)
445
446
447class ProtoTree(DirectoryTree):
448    """Describes one or more proto directories as a dictionary of
449    RealFileInfo objects, indexed by relative path.
450    """
451
452    def adddir(self, proto, exceptions):
453        """Extends the ProtoTree dictionary with RealFileInfo
454        objects describing the proto dir, indexed by relative
455        path.
456        """
457        newentries = {}
458
459        pdir = os.path.normpath(proto)
460        strippdir = lambda r, n: os.path.join(r, n)[len(pdir)+1:]
461        for root, dirs, files in os.walk(pdir):
462            for name in dirs + files:
463                path = strippdir(root, name)
464                if path not in exceptions:
465                    try:
466                        newentries[path] = RealFileInfo(pdir, path)
467                    except OSError, e:
468                        sys.stderr.write("Warning: unable to stat %s: %s\n" %
469                            (path, e))
470                        continue
471                else:
472                    exceptions.remove(path)
473                    if name in dirs:
474                        dirs.remove(name)
475
476        #
477        # Find the sets of paths in this proto dir that are hardlinks
478        # to the same inode.
479        #
480        # It seems wasteful to store this in each FileInfo, but we
481        # otherwise need a linking mechanism.  With this information
482        # here, FileInfo object comparison can be self contained.
483        #
484        # We limit this aggregation to a single proto dir, as
485        # represented by newentries.  That means we don't need to care
486        # about proto dirs on separate filesystems, or about hardlinks
487        # that cross proto dir boundaries.
488        #
489        hk2path = {}
490        for path, fileinfo in newentries.iteritems():
491            if fileinfo.hardkey:
492                hk2path.setdefault(fileinfo.hardkey, set()).add(path)
493        for fileinfo in newentries.itervalues():
494            if fileinfo.hardkey:
495                fileinfo.hardpaths.update(hk2path[fileinfo.hardkey])
496        self.update(newentries)
497
498    def addprotolist(self, protolist, exceptions):
499        """Read in the specified file, assumed to be the
500        output of protolist.
501
502        This has been tested minimally, and is potentially useful for
503        comparing across the transition period, but should ultimately
504        go away.
505        """
506
507        try:
508            plist = open(protolist)
509        except IOError, exc:
510            raise IOError("cannot open proto list: %s" % str(exc))
511
512        newentries = {}
513
514        for pline in plist:
515            pline = pline.split()
516            #
517            # Use a FileInfo() object instead of a RealFileInfo()
518            # object because we want to avoid the RealFileInfo
519            # constructor, because there's nothing to actually stat().
520            #
521            fileinfo = FileInfo()
522            try:
523                if pline[1] in exceptions:
524                    exceptions.remove(pline[1])
525                    continue
526                if pline[0] == "d":
527                    fileinfo.isdir = True
528                fileinfo.path = pline[1]
529                if pline[2] != "-":
530                    fileinfo.target = os.path.normpath(pline[2])
531                fileinfo.mode = int("0%s" % pline[3])
532                fileinfo.owner = pline[4]
533                fileinfo.group = pline[5]
534                if pline[6] != "0":
535                    fileinfo.hardkey = pline[6]
536                newentries[pline[1]] = fileinfo
537            except IndexError:
538                raise BadProtolistFormat(pline)
539
540        plist.close()
541        hk2path = {}
542        for path, fileinfo in newentries.iteritems():
543            if fileinfo.hardkey:
544                hk2path.setdefault(fileinfo.hardkey, set()).add(path)
545        for fileinfo in newentries.itervalues():
546            if fileinfo.hardkey:
547                fileinfo.hardpaths.update(hk2path[fileinfo.hardkey])
548        self.update(newentries)
549
550
551class ManifestParsingError(Exception):
552    """This means that the Manifest.set_content() raised an
553    ActionError.  We raise this, instead, to tell us which manifest
554    could not be parsed, rather than what action error we hit.
555    """
556    def __init__(self, mfile, error):
557        Exception.__init__(self)
558        self.mfile = mfile
559        self.error = error
560
561    def __str__(self):
562        return "unable to parse manifest %s: %s" % (self.mfile, self.error)
563
564
565class ManifestTree(DirectoryTree):
566    """Describes one or more directories containing arbitrarily
567    many manifests as a dictionary of ActionInfo objects, indexed
568    by the relative path of the data source within the proto area.
569    That path may or may not be the same as the path attribute of the
570    given action.
571    """
572
573    def addmanifest(self, root, mfile, arch, modechecks, exceptions):
574        """Treats the specified input file as a pkg(5) package
575        manifest, and extends the ManifestTree dictionary with entries
576        for the actions therein.
577        """
578        mfest = manifest.Manifest()
579        try:
580            mfest.set_content(open(os.path.join(root, mfile)).read())
581        except IOError, exc:
582            raise IOError("cannot read manifest: %s" % str(exc))
583        except actions.ActionError, exc:
584            raise ManifestParsingError(mfile, str(exc))
585
586        #
587        # Make sure the manifest is applicable to the user-specified
588        # architecture.  Assumption: if variant.arch is not an
589        # attribute of the manifest, then the package should be
590        # installed on all architectures.
591        #
592        if arch not in mfest.attributes.get("variant.arch", (arch,)):
593            return
594
595        modewarnings = set()
596        for action in mfest.gen_actions():
597            if "path" not in action.attrs or \
598                not ActionInfo.supported(action.name):
599                continue
600
601            #
602            # The dir action is currently fully specified, in that it
603            # lists owner, group, and mode attributes.  If that
604            # changes in pkg(5) code, we'll need to revisit either this
605            # code or the ActionInfo() constructor.  It's possible
606            # that the pkg(5) system could be extended to provide a
607            # mechanism for specifying directory permissions outside
608            # of the individual manifests that deliver files into
609            # those directories.  Doing so at time of manifest
610            # processing would mean that validate_pkg continues to work,
611            # but doing so at time of publication would require updates.
612            #
613
614            #
615            # See pkgsend(1) for the use of NOHASH for objects with
616            # datastreams.  Currently, that means "files," but this
617            # should work for any other such actions.
618            #
619            if getattr(action, "hash", "NOHASH") != "NOHASH":
620                path = action.hash
621            else:
622                path = action.attrs["path"]
623
624            #
625            # This is the wrong tool in which to enforce consistency
626            # on a set of manifests.  So instead of comparing the
627            # different actions with the same "path" attribute, we
628            # use the first one.
629            #
630            if path in self:
631                continue
632
633            #
634            # As with the manifest itself, if an action has specified
635            # variant.arch, we look for the target architecture
636            # therein.
637            #
638            var = None
639
640            #
641            # The name of this method changed in pkg(5) build 150, we need to
642            # work with both sets.
643            #
644            if hasattr(action, 'get_variants'):
645                var = action.get_variants()
646            else:
647                var = action.get_variant_template()
648            if "variant.arch" in var and arch not in var["variant.arch"]:
649                return
650
651            self[path] = ActionInfo(action)
652            if modechecks is not None and path not in exceptions:
653                modewarnings.update(self[path].checkmodes(modechecks))
654
655        if len(modewarnings) > 0:
656            print "warning: unsafe permissions in %s" % mfile
657            for w in sorted(modewarnings):
658                print w
659            print ""
660
661    def adddir(self, mdir, arch, modechecks, exceptions):
662        """Walks the specified directory looking for pkg(5) manifests.
663        """
664        for mfile in os.listdir(mdir):
665            if (mfile.endswith(".mog") and
666                stat.S_ISREG(os.lstat(os.path.join(mdir, mfile)).st_mode)):
667                try:
668                    self.addmanifest(mdir, mfile, arch, modechecks, exceptions)
669                except IOError, exc:
670                    sys.stderr.write("warning: %s\n" % str(exc))
671
672    def resolvehardlinks(self):
673        """Populates mode, group, and owner for resolved (ie link target
674        is present in the manifest tree) hard links.
675        """
676        for info in self.values():
677            if info.name() == "hardlink":
678                tgt = info.hardkey
679                if tgt in self:
680                    tgtinfo = self[tgt]
681                    info.owner = tgtinfo.owner
682                    info.group = tgtinfo.group
683                    info.mode = tgtinfo.mode
684
685class ExceptionList(set):
686    """Keep track of an exception list as a set of paths to be excluded
687    from any other lists we build.
688    """
689
690    def __init__(self, files, arch):
691        set.__init__(self)
692        for fname in files:
693            try:
694                self.readexceptionfile(fname, arch)
695            except IOError, exc:
696                sys.stderr.write("warning: cannot read exception file: %s\n" %
697                    str(exc))
698
699    def readexceptionfile(self, efile, arch):
700        """Build a list of all pathnames from the specified file that
701        either apply to all architectures (ie which have no trailing
702        architecture tokens), or to the specified architecture (ie
703        which have the value of the arch arg as a trailing
704        architecture token.)
705        """
706
707        excfile = open(efile)
708
709        for exc in excfile:
710            exc = exc.split()
711            if len(exc) and exc[0][0] != "#":
712                if arch in (exc[1:] or arch):
713                    self.add(os.path.normpath(exc[0]))
714
715        excfile.close()
716
717
718USAGE = """%s [-v] -a arch [-e exceptionfile]... [-L|-M [-X check]...] input_1 [input_2]
719
720where input_1 and input_2 may specify proto lists, proto areas,
721or manifest directories.  For proto lists, use one or more
722
723    -l file
724
725arguments.  For proto areas, use one or more
726
727    -p dir
728
729arguments.  For manifest directories, use one or more
730
731    -m dir
732
733arguments.
734
735If -L or -M is specified, then only one input source is allowed, and
736it should be one or more manifest directories.  These two options are
737mutually exclusive.
738
739The -L option is used to generate a proto list to stdout.
740
741The -M option is used to check for safe file and directory modes.
742By default, this causes all mode checks to be performed.  Individual
743mode checks may be turned off using "-X check," where "check" comes
744from the following set of checks:
745
746    m   check for group or other write permissions
747    w   check for user write permissions on files and directories
748        not owned by root
749    s   check for group/other read permission on executable files
750        that have setuid/setgid bit(s)
751    o   check for files that could be safely owned by root
752""" % sys.argv[0]
753
754
755def usage(msg=None):
756    """Try to give the user useful information when they don't get the
757    command syntax right.
758    """
759    if msg:
760        sys.stderr.write("%s: %s\n" % (sys.argv[0], msg))
761    sys.stderr.write(USAGE)
762    sys.exit(2)
763
764
765def main(argv):
766    """Compares two out of three possible data sources: a proto list, a
767    set of proto areas, and a set of manifests.
768    """
769    try:
770        opts, args = getopt.getopt(argv, 'a:e:Ll:Mm:p:vX:')
771    except getopt.GetoptError, exc:
772        usage(str(exc))
773
774    if args:
775        usage()
776
777    arch = None
778    exceptionlists = []
779    listonly = False
780    manifestdirs = []
781    manifesttree = ManifestTree("manifests")
782    protodirs = []
783    prototree = ProtoTree("proto area")
784    protolists = []
785    protolist = ProtoTree("proto list")
786    modechecks = set()
787    togglemodechecks = set()
788    trees = []
789    comparing = set()
790    verbose = False
791
792    for opt, arg in opts:
793        if opt == "-a":
794            if arch:
795                usage("may only specify one architecture")
796            else:
797                arch = arg
798        elif opt == "-e":
799            exceptionlists.append(arg)
800        elif opt == "-L":
801            listonly = True
802        elif opt == "-l":
803            comparing.add("protolist")
804            protolists.append(os.path.normpath(arg))
805        elif opt == "-M":
806            modechecks.update(DEFAULTMODECHECKS)
807        elif opt == "-m":
808            comparing.add("manifests")
809            manifestdirs.append(os.path.normpath(arg))
810        elif opt == "-p":
811            comparing.add("proto area")
812            protodirs.append(os.path.normpath(arg))
813        elif opt == "-v":
814            verbose = True
815        elif opt == "-X":
816            togglemodechecks.add(arg)
817
818    if listonly or len(modechecks) > 0:
819        if len(comparing) != 1 or "manifests" not in comparing:
820            usage("-L and -M require one or more -m args, and no -l or -p")
821        if listonly and len(modechecks) > 0:
822            usage("-L and -M are mutually exclusive")
823    elif len(comparing) != 2:
824        usage("must specify exactly two of -l, -m, and -p")
825
826    if len(togglemodechecks) > 0 and len(modechecks) == 0:
827        usage("-X requires -M")
828
829    for s in togglemodechecks:
830        if s not in ALLMODECHECKS:
831            usage("unknown mode check %s" % s)
832        modechecks.symmetric_difference_update((s))
833
834    if len(modechecks) == 0:
835        modechecks = None
836
837    if not arch:
838        usage("must specify architecture")
839
840    exceptions = ExceptionList(exceptionlists, arch)
841    originalexceptions = exceptions.copy()
842
843    if len(manifestdirs) > 0:
844        for mdir in manifestdirs:
845            manifesttree.adddir(mdir, arch, modechecks, exceptions)
846        if listonly:
847            manifesttree.resolvehardlinks()
848            for info in manifesttree.values():
849                print "%s" % info.protostr()
850            sys.exit(0)
851        if modechecks is not None:
852            sys.exit(0)
853        trees.append(manifesttree)
854
855    if len(protodirs) > 0:
856        for pdir in protodirs:
857            prototree.adddir(pdir, exceptions)
858        trees.append(prototree)
859
860    if len(protolists) > 0:
861        for plist in protolists:
862            try:
863                protolist.addprotolist(plist, exceptions)
864            except IOError, exc:
865                sys.stderr.write("warning: %s\n" % str(exc))
866        trees.append(protolist)
867
868    if verbose and exceptions:
869        print "Entries present in exception list but missing from proto area:"
870        for exc in sorted(exceptions):
871            print "\t%s" % exc
872        print ""
873
874    usedexceptions = originalexceptions.difference(exceptions)
875    harmfulexceptions = usedexceptions.intersection(manifesttree)
876    if harmfulexceptions:
877        print "Entries present in exception list but also in manifests:"
878        for exc in sorted(harmfulexceptions):
879            print "\t%s" % exc
880            del manifesttree[exc]
881        print ""
882
883    trees[0].compare(trees[1])
884
885if __name__ == '__main__':
886    try:
887        main(sys.argv[1:])
888    except KeyboardInterrupt:
889        sys.exit(1)
890    except IOError:
891        sys.exit(1)
892