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