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