xref: /illumos-gate/usr/src/tools/scripts/validate_pkg.py (revision 014740def2fccbcada139c94bad0664bfc172ab5)
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 
36 import getopt
37 import os
38 import stat
39 import sys
40 
41 from pkg import actions
42 from 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 #
50 OUTPUTMAP = {
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
76 ALLMODECHECKS = frozenset(("m", "w", "s", "o"))
77 DEFAULTMODECHECKS = frozenset(("m", "w", "o"))
78 
79 class 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 
288 class 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 
332 class 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 
348 class 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 
400 class 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 
444 class 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 
453 class 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 
557 class 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 
571 class 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 
691 class 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 
724 USAGE = """%s [-v] -a arch [-e exceptionfile]... [-L|-M [-X check]...] input_1 [input_2]
725 
726 where input_1 and input_2 may specify proto lists, proto areas,
727 or manifest directories.  For proto lists, use one or more
728 
729     -l file
730 
731 arguments.  For proto areas, use one or more
732 
733     -p dir
734 
735 arguments.  For manifest directories, use one or more
736 
737     -m dir
738 
739 arguments.
740 
741 If -L or -M is specified, then only one input source is allowed, and
742 it should be one or more manifest directories.  These two options are
743 mutually exclusive.
744 
745 The -L option is used to generate a proto list to stdout.
746 
747 The -M option is used to check for safe file and directory modes.
748 By default, this causes all mode checks to be performed.  Individual
749 mode checks may be turned off using "-X check," where "check" comes
750 from 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 
761 def 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 
771 def 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 
891 if __name__ == '__main__':
892     try:
893         main(sys.argv[1:])
894     except KeyboardInterrupt:
895         sys.exit(1)
896     except IOError:
897         sys.exit(1)
898