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
288class ActionInfoError(Exception):
289    def __init__(self, action, error):
290        Exception.__init__(self)
291        self.action = action
292        self.error = error
293
294    def __str__(self):
295        return "Error in '%s': %s" % (self.action, self.error)
296
297
298class ActionInfo(FileInfo):
299    """Object to track information about manifest actions.
300
301    This currently understands file, link, dir, and hardlink actions.
302    """
303
304    def __init__(self, action):
305        FileInfo.__init__(self)
306        #
307        # Currently, all actions that we support have a "path"
308        # attribute.  If that changes, then we'll need to
309        # catch a KeyError from this assignment.
310        #
311        self.path = action.attrs["path"]
312
313        if action.name == "file":
314            self.owner = action.attrs["owner"]
315            self.group = action.attrs["group"]
316            self.mode = action.attrs["mode"]
317            self.hash = action.hash
318            if "preserve" in action.attrs:
319                self.editable = True
320        elif action.name == "link":
321            try:
322                target = action.attrs["target"]
323            except KeyError:
324                raise ActionInfoError(str(action),
325                    "Missing 'target' attribute")
326            else:
327                self.target = os.path.normpath(target)
328                self.mediator = action.attrs.get("mediator")
329        elif action.name == "dir":
330            self.owner = action.attrs["owner"]
331            self.group = action.attrs["group"]
332            self.mode = action.attrs["mode"]
333            self.isdir = True
334        elif action.name == "hardlink":
335            try:
336                target = os.path.normpath(action.get_target_path())
337            except KeyError:
338                raise ActionInfoError(str(action),
339                    "Missing 'target' attribute")
340            else:
341                self.hardkey = target
342                self.hardpaths.add(target)
343
344    @staticmethod
345    def supported(action):
346        """Indicates whether the specified IPS action time is
347        correctly handled by the ActionInfo constructor.
348        """
349        return action in frozenset(("file", "dir", "link", "hardlink"))
350
351
352class UnsupportedFileFormatError(Exception):
353    """This means that the stat.S_IFMT returned something we don't
354    support, ie a pipe or socket.  If it's appropriate for such an
355    object to be in the proto area, then the RealFileInfo constructor
356    will need to evolve to support it, or it will need to be in the
357    exception list.
358    """
359    def __init__(self, path, mode):
360        Exception.__init__(self)
361        self.path = path
362        self.mode = mode
363
364    def __str__(self):
365        return '%s: unsupported S_IFMT %07o' % (self.path, self.mode)
366
367
368class RealFileInfo(FileInfo):
369    """Object to track important-to-packaging file information.
370
371    This currently handles regular files, directories, and symbolic links.
372
373    For multiple RealFileInfo objects with identical hardkeys, there
374    is no way to determine which of the hard links should be
375    delivered as a file, and which as hardlinks.
376    """
377
378    def __init__(self, root=None, path=None):
379        FileInfo.__init__(self)
380        self.path = path
381        path = os.path.join(root, path)
382        lstat = os.lstat(path)
383        mode = lstat.st_mode
384
385        #
386        # Per stat.py, these cases are mutually exclusive.
387        #
388        if stat.S_ISREG(mode):
389            self.hash = self.path
390        elif stat.S_ISDIR(mode):
391            self.isdir = True
392        elif stat.S_ISLNK(mode):
393            self.target = os.path.normpath(os.readlink(path))
394            self.mediator = None
395        else:
396            raise UnsupportedFileFormatError(path, mode)
397
398        if not stat.S_ISLNK(mode):
399            self.mode = "%04o" % stat.S_IMODE(mode)
400            #
401            # Instead of reading the group and owner from the proto area after
402            # a non-root build, just drop in dummy values.  Since we don't
403            # compare them anywhere, this should allow at least marginally
404            # useful comparisons of protolist-style output.
405            #
406            self.owner = "owner"
407            self.group = "group"
408
409        #
410        # refcount > 1 indicates a hard link
411        #
412        if lstat.st_nlink > 1:
413            #
414            # This could get ugly if multiple proto areas reside
415            # on different filesystems.
416            #
417            self.hardkey = lstat.st_ino
418
419
420class DirectoryTree(dict):
421    """Meant to be subclassed according to population method.
422    """
423    def __init__(self, name):
424        dict.__init__(self)
425        self.name = name
426
427    def compare(self, other):
428        """Compare two different sets of FileInfo objects.
429        """
430        keys1 = frozenset(list(self.keys()))
431        keys2 = frozenset(list(other.keys()))
432
433        common = keys1.intersection(keys2)
434        onlykeys1 = keys1.difference(common)
435        onlykeys2 = keys2.difference(common)
436
437        if onlykeys1:
438            print("Entries present in %s but not %s:" % \
439                (self.name, other.name))
440            for path in sorted(onlykeys1):
441                print(("\t%s" % str(self[path])))
442            print("")
443
444        if onlykeys2:
445            print("Entries present in %s but not %s:" % \
446                (other.name, self.name))
447            for path in sorted(onlykeys2):
448                print(("\t%s" % str(other[path])))
449            print("")
450
451        nodifferences = True
452        for path in sorted(common):
453            if self[path] != other[path]:
454                if nodifferences:
455                    nodifferences = False
456                    print("Entries that differ between %s and %s:" \
457                        % (self.name, other.name))
458                print(("%14s %s" % (self.name, self[path])))
459                print(("%14s %s" % (other.name, other[path])))
460        if not nodifferences:
461            print("")
462
463
464class BadProtolistFormat(Exception):
465    """This means that the user supplied a file via -l, but at least
466    one line from that file doesn't have the right number of fields to
467    parse as protolist output.
468    """
469    def __str__(self):
470        return 'bad proto list entry: "%s"' % Exception.__str__(self)
471
472
473class ProtoTree(DirectoryTree):
474    """Describes one or more proto directories as a dictionary of
475    RealFileInfo objects, indexed by relative path.
476    """
477
478    def adddir(self, proto, exceptions):
479        """Extends the ProtoTree dictionary with RealFileInfo
480        objects describing the proto dir, indexed by relative
481        path.
482        """
483        newentries = {}
484
485        pdir = os.path.normpath(proto)
486        strippdir = lambda r, n: os.path.join(r, n)[len(pdir)+1:]
487        for root, dirs, files in os.walk(pdir):
488            for name in dirs + files:
489                path = strippdir(root, name)
490                if path not in exceptions:
491                    try:
492                        newentries[path] = RealFileInfo(pdir, path)
493                    except OSError as e:
494                        sys.stderr.write("Warning: unable to stat %s: %s\n" %
495                            (path, e))
496                        continue
497                else:
498                    exceptions.remove(path)
499                    if name in dirs:
500                        dirs.remove(name)
501
502        #
503        # Find the sets of paths in this proto dir that are hardlinks
504        # to the same inode.
505        #
506        # It seems wasteful to store this in each FileInfo, but we
507        # otherwise need a linking mechanism.  With this information
508        # here, FileInfo object comparison can be self contained.
509        #
510        # We limit this aggregation to a single proto dir, as
511        # represented by newentries.  That means we don't need to care
512        # about proto dirs on separate filesystems, or about hardlinks
513        # that cross proto dir boundaries.
514        #
515        hk2path = {}
516        for path, fileinfo in newentries.items():
517            if fileinfo.hardkey:
518                hk2path.setdefault(fileinfo.hardkey, set()).add(path)
519        for fileinfo in newentries.values():
520            if fileinfo.hardkey:
521                fileinfo.hardpaths.update(hk2path[fileinfo.hardkey])
522        self.update(newentries)
523
524    def addprotolist(self, protolist, exceptions):
525        """Read in the specified file, assumed to be the
526        output of protolist.
527
528        This has been tested minimally, and is potentially useful for
529        comparing across the transition period, but should ultimately
530        go away.
531        """
532
533        try:
534            plist = open(protolist)
535        except IOError as exc:
536            raise IOError("cannot open proto list: %s" % str(exc))
537
538        newentries = {}
539
540        for pline in plist:
541            pline = pline.split()
542            #
543            # Use a FileInfo() object instead of a RealFileInfo()
544            # object because we want to avoid the RealFileInfo
545            # constructor, because there's nothing to actually stat().
546            #
547            fileinfo = FileInfo()
548            try:
549                if pline[1] in exceptions:
550                    exceptions.remove(pline[1])
551                    continue
552                if pline[0] == "d":
553                    fileinfo.isdir = True
554                fileinfo.path = pline[1]
555                if pline[2] != "-":
556                    fileinfo.target = os.path.normpath(pline[2])
557                fileinfo.mode = int("0%s" % pline[3])
558                fileinfo.owner = pline[4]
559                fileinfo.group = pline[5]
560                if pline[6] != "0":
561                    fileinfo.hardkey = pline[6]
562                newentries[pline[1]] = fileinfo
563            except IndexError:
564                raise BadProtolistFormat(pline)
565
566        plist.close()
567        hk2path = {}
568        for path, fileinfo in newentries.items():
569            if fileinfo.hardkey:
570                hk2path.setdefault(fileinfo.hardkey, set()).add(path)
571        for fileinfo in newentries.values():
572            if fileinfo.hardkey:
573                fileinfo.hardpaths.update(hk2path[fileinfo.hardkey])
574        self.update(newentries)
575
576
577class ManifestParsingError(Exception):
578    """This means that the Manifest.set_content() raised an
579    ActionError.  We raise this, instead, to tell us which manifest
580    could not be parsed, rather than what action error we hit.
581    """
582    def __init__(self, mfile, error):
583        Exception.__init__(self)
584        self.mfile = mfile
585        self.error = error
586
587    def __str__(self):
588        return "unable to parse manifest %s: %s" % (self.mfile, self.error)
589
590class ManifestTree(DirectoryTree):
591    """Describes one or more directories containing arbitrarily
592    many manifests as a dictionary of ActionInfo objects, indexed
593    by the relative path of the data source within the proto area.
594    That path may or may not be the same as the path attribute of the
595    given action.
596    """
597
598    def addmanifest(self, root, mfile, arch, modechecks, exceptions):
599        """Treats the specified input file as a pkg(7) package
600        manifest, and extends the ManifestTree dictionary with entries
601        for the actions therein.
602        """
603        mfest = manifest.Manifest()
604        try:
605            mfest.set_content(open(os.path.join(root, mfile)).read())
606        except IOError as exc:
607            raise IOError("cannot read manifest: %s" % str(exc))
608        except actions.ActionError as exc:
609            raise ManifestParsingError(mfile, str(exc))
610
611        #
612        # Make sure the manifest is applicable to the user-specified
613        # architecture.  Assumption: if variant.arch is not an
614        # attribute of the manifest, then the package should be
615        # installed on all architectures.
616        #
617        if arch not in mfest.attributes.get("variant.arch", (arch,)):
618            return
619
620        modewarnings = set()
621        for action in mfest.gen_actions():
622            if "path" not in action.attrs or \
623                not ActionInfo.supported(action.name):
624                continue
625
626            #
627            # The dir action is currently fully specified, in that it
628            # lists owner, group, and mode attributes.  If that
629            # changes in pkg(7) code, we'll need to revisit either this
630            # code or the ActionInfo() constructor.  It's possible
631            # that the pkg(7) system could be extended to provide a
632            # mechanism for specifying directory permissions outside
633            # of the individual manifests that deliver files into
634            # those directories.  Doing so at time of manifest
635            # processing would mean that validate_pkg continues to work,
636            # but doing so at time of publication would require updates.
637            #
638
639            #
640            # See pkgsend(1) for the use of NOHASH for objects with
641            # datastreams.  Currently, that means "files," but this
642            # should work for any other such actions.
643            #
644            if getattr(action, "hash", "NOHASH") != "NOHASH":
645                path = action.hash
646            else:
647                path = action.attrs["path"]
648
649            #
650            # This is the wrong tool in which to enforce consistency
651            # on a set of manifests.  So instead of comparing the
652            # different actions with the same "path" attribute, we
653            # use the first one.
654            #
655            if path in self:
656                continue
657
658            #
659            # As with the manifest itself, if an action has specified
660            # variant.arch, we look for the target architecture
661            # therein.
662            #
663            var = None
664
665            #
666            # The name of this method changed in pkg(7) build 150, we need to
667            # work with both sets.
668            #
669            if hasattr(action, 'get_variants'):
670                var = action.get_variants()
671            else:
672                var = action.get_variant_template()
673            if "variant.arch" in var and arch not in var["variant.arch"]:
674                return
675
676            try:
677                self[path] = ActionInfo(action)
678            except ActionInfoError as e:
679                sys.stderr.write("warning: %s\n" % str(e))
680
681            if modechecks is not None and path not in exceptions:
682                modewarnings.update(self[path].checkmodes(modechecks))
683
684        if len(modewarnings) > 0:
685            print("warning: unsafe permissions in %s" % mfile)
686            for w in sorted(modewarnings):
687                print(w)
688            print("")
689
690    def adddir(self, mdir, arch, modechecks, exceptions):
691        """Walks the specified directory looking for pkg(7) manifests.
692        """
693        for mfile in os.listdir(mdir):
694            if (mfile.endswith(".mog") and
695                stat.S_ISREG(os.lstat(os.path.join(mdir, mfile)).st_mode)):
696                try:
697                    self.addmanifest(mdir, mfile, arch, modechecks, exceptions)
698                except IOError as exc:
699                    sys.stderr.write("warning: %s\n" % str(exc))
700
701    def resolvehardlinks(self):
702        """Populates mode, group, and owner for resolved (ie link target
703        is present in the manifest tree) hard links.
704        """
705        for info in list(self.values()):
706            if info.name() == "hardlink":
707                tgt = info.hardkey
708                if tgt in self:
709                    tgtinfo = self[tgt]
710                    info.owner = tgtinfo.owner
711                    info.group = tgtinfo.group
712                    info.mode = tgtinfo.mode
713
714class ExceptionList(set):
715    """Keep track of an exception list as a set of paths to be excluded
716    from any other lists we build.
717    """
718
719    def __init__(self, files, arch):
720        set.__init__(self)
721        for fname in files:
722            try:
723                self.readexceptionfile(fname, arch)
724            except IOError as exc:
725                sys.stderr.write("warning: cannot read exception file: %s\n" %
726                    str(exc))
727
728    def readexceptionfile(self, efile, arch):
729        """Build a list of all pathnames from the specified file that
730        either apply to all architectures (ie which have no trailing
731        architecture tokens), or to the specified architecture (ie
732        which have the value of the arch arg as a trailing
733        architecture token.)
734        """
735
736        excfile = open(efile)
737
738        for exc in excfile:
739            exc = exc.split()
740            if len(exc) and exc[0][0] != "#":
741                if arch in (exc[1:] or arch):
742                    self.add(os.path.normpath(exc[0]))
743
744        excfile.close()
745
746
747USAGE = """%s [-v] -a arch [-e exceptionfile]... [-L|-M [-X check]...] input_1 [input_2]
748
749where input_1 and input_2 may specify proto lists, proto areas,
750or manifest directories.  For proto lists, use one or more
751
752    -l file
753
754arguments.  For proto areas, use one or more
755
756    -p dir
757
758arguments.  For manifest directories, use one or more
759
760    -m dir
761
762arguments.
763
764If -L or -M is specified, then only one input source is allowed, and
765it should be one or more manifest directories.  These two options are
766mutually exclusive.
767
768The -L option is used to generate a proto list to stdout.
769
770The -M option is used to check for safe file and directory modes.
771By default, this causes all mode checks to be performed.  Individual
772mode checks may be turned off using "-X check," where "check" comes
773from the following set of checks:
774
775    m   check for group or other write permissions
776    w   check for user write permissions on files and directories
777        not owned by root
778    s   check for group/other read permission on executable files
779        that have setuid/setgid bit(s)
780    o   check for files that could be safely owned by root
781""" % sys.argv[0]
782
783
784def usage(msg=None):
785    """Try to give the user useful information when they don't get the
786    command syntax right.
787    """
788    if msg:
789        sys.stderr.write("%s: %s\n" % (sys.argv[0], msg))
790    sys.stderr.write(USAGE)
791    sys.exit(2)
792
793
794def main(argv):
795    """Compares two out of three possible data sources: a proto list, a
796    set of proto areas, and a set of manifests.
797    """
798    try:
799        opts, args = getopt.getopt(argv, 'a:e:Ll:Mm:p:vX:')
800    except getopt.GetoptError as exc:
801        usage(str(exc))
802
803    if args:
804        usage()
805
806    arch = None
807    exceptionlists = []
808    listonly = False
809    manifestdirs = []
810    manifesttree = ManifestTree("manifests")
811    protodirs = []
812    prototree = ProtoTree("proto area")
813    protolists = []
814    protolist = ProtoTree("proto list")
815    modechecks = set()
816    togglemodechecks = set()
817    trees = []
818    comparing = set()
819    verbose = False
820
821    for opt, arg in opts:
822        if opt == "-a":
823            if arch:
824                usage("may only specify one architecture")
825            else:
826                arch = arg
827        elif opt == "-e":
828            exceptionlists.append(arg)
829        elif opt == "-L":
830            listonly = True
831        elif opt == "-l":
832            comparing.add("protolist")
833            protolists.append(os.path.normpath(arg))
834        elif opt == "-M":
835            modechecks.update(DEFAULTMODECHECKS)
836        elif opt == "-m":
837            comparing.add("manifests")
838            manifestdirs.append(os.path.normpath(arg))
839        elif opt == "-p":
840            comparing.add("proto area")
841            protodirs.append(os.path.normpath(arg))
842        elif opt == "-v":
843            verbose = True
844        elif opt == "-X":
845            togglemodechecks.add(arg)
846
847    if listonly or len(modechecks) > 0:
848        if len(comparing) != 1 or "manifests" not in comparing:
849            usage("-L and -M require one or more -m args, and no -l or -p")
850        if listonly and len(modechecks) > 0:
851            usage("-L and -M are mutually exclusive")
852    elif len(comparing) != 2:
853        usage("must specify exactly two of -l, -m, and -p")
854
855    if len(togglemodechecks) > 0 and len(modechecks) == 0:
856        usage("-X requires -M")
857
858    for s in togglemodechecks:
859        if s not in ALLMODECHECKS:
860            usage("unknown mode check %s" % s)
861        modechecks.symmetric_difference_update((s))
862
863    if len(modechecks) == 0:
864        modechecks = None
865
866    if not arch:
867        usage("must specify architecture")
868
869    exceptions = ExceptionList(exceptionlists, arch)
870    originalexceptions = exceptions.copy()
871
872    if len(manifestdirs) > 0:
873        for mdir in manifestdirs:
874            manifesttree.adddir(mdir, arch, modechecks, exceptions)
875        if listonly:
876            manifesttree.resolvehardlinks()
877            for info in list(manifesttree.values()):
878                print("%s" % info.protostr())
879            sys.exit(0)
880        if modechecks is not None:
881            sys.exit(0)
882        trees.append(manifesttree)
883
884    if len(protodirs) > 0:
885        for pdir in protodirs:
886            prototree.adddir(pdir, exceptions)
887        trees.append(prototree)
888
889    if len(protolists) > 0:
890        for plist in protolists:
891            try:
892                protolist.addprotolist(plist, exceptions)
893            except IOError as exc:
894                sys.stderr.write("warning: %s\n" % str(exc))
895        trees.append(protolist)
896
897    if verbose and exceptions:
898        print("Entries present in exception list but missing from proto area:")
899        for exc in sorted(exceptions):
900            print("\t%s" % exc)
901        print("")
902
903    usedexceptions = originalexceptions.difference(exceptions)
904    harmfulexceptions = usedexceptions.intersection(manifesttree)
905    if harmfulexceptions:
906        print("Entries present in exception list but also in manifests:")
907        for exc in sorted(harmfulexceptions):
908            print("\t%s" % exc)
909            del manifesttree[exc]
910        print("")
911
912    trees[0].compare(trees[1])
913
914if __name__ == '__main__':
915    locale.setlocale(locale.LC_ALL, "")
916    gettext.install("pkg", "/usr/share/locale")
917
918    try:
919        main(sys.argv[1:])
920    except KeyboardInterrupt:
921        sys.exit(1)
922    except IOError:
923        sys.exit(1)
924