1#############################################################################
2##
3## Copyright (C) 2017 The Qt Company Ltd.
4## Contact: https://www.qt.io/licensing/
5##
6## This file is part of Qt for Python.
7##
8## $QT_BEGIN_LICENSE:LGPL$
9## Commercial License Usage
10## Licensees holding valid commercial Qt licenses may use this file in
11## accordance with the commercial license agreement provided with the
12## Software or, alternatively, in accordance with the terms contained in
13## a written agreement between you and The Qt Company. For licensing terms
14## and conditions see https://www.qt.io/terms-conditions. For further
15## information use the contact form at https://www.qt.io/contact-us.
16##
17## GNU Lesser General Public License Usage
18## Alternatively, this file may be used under the terms of the GNU Lesser
19## General Public License version 3 as published by the Free Software
20## Foundation and appearing in the file LICENSE.LGPL3 included in the
21## packaging of this file. Please review the following information to
22## ensure the GNU Lesser General Public License version 3 requirements
23## will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
24##
25## GNU General Public License Usage
26## Alternatively, this file may be used under the terms of the GNU
27## General Public License version 2.0 or (at your option) the GNU General
28## Public license version 3 or any later version approved by the KDE Free
29## Qt Foundation. The licenses are as published by the Free Software
30## Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
31## included in the packaging of this file. Please review the following
32## information to ensure the GNU General Public License requirements will
33## be met: https://www.gnu.org/licenses/gpl-2.0.html and
34## https://www.gnu.org/licenses/gpl-3.0.html.
35##
36## $QT_END_LICENSE$
37##
38#############################################################################
39
40#!/usr/bin/env python
41#
42# checklibs.py
43#
44# Check Mach-O dependencies.
45#
46# See http://www.entropy.ch/blog/Developer/2011/03/05/2011-Update-to-checklibs-Script-for-dynamic-library-dependencies.html
47#
48# Written by Marc Liyanage <http://www.entropy.ch>
49#
50#
51
52import subprocess, sys, re, os.path, optparse, collections
53from pprint import pprint
54
55
56class MachOFile:
57
58    def __init__(self, image_path, arch, parent = None):
59        self.image_path = image_path
60        self._dependencies = []
61        self._cache = dict(paths = {}, order = [])
62        self.arch = arch
63        self.parent = parent
64        self.header_info = {}
65        self.load_info()
66        self.add_to_cache()
67
68    def load_info(self):
69        if not self.image_path.exists():
70            return
71        self.load_header()
72        self.load_rpaths()
73
74    def load_header(self):
75        # Get the mach-o header info, we're interested in the file type
76        # (executable, dylib)
77        cmd = 'otool -arch {0} -h "{1}"'
78        output = self.shell(cmd, [self.arch, self.image_path.resolved_path],
79            fatal = True)
80        if not output:
81            print("Unable to load mach header for {} ({}), architecture "
82                "mismatch? Use --arch option to pick architecture".format(
83                self.image_path.resolved_path, self.arch), file=sys.stderr)
84            exit()
85        (keys, values) = output.splitlines()[2:]
86        self.header_info = dict(zip(keys.split(), values.split()))
87
88    def load_rpaths(self):
89        output = self.shell('otool -arch {0} -l "{1}"',
90            [self.arch, self.image_path.resolved_path], fatal = True)
91        # skip file name on first line
92        load_commands = re.split('Load command (\d+)', output)[1:]
93        self._rpaths = []
94        load_commands = collections.deque(load_commands)
95        while load_commands:
96            load_commands.popleft() # command index
97            command = load_commands.popleft().strip().splitlines()
98            if command[0].find('LC_RPATH') == -1:
99                continue
100
101            path = re.findall('path (.+) \(offset \d+\)$', command[2])[0]
102            image_path = self.image_path_for_recorded_path(path)
103            image_path.rpath_source = self
104            self._rpaths.append(image_path)
105
106    def ancestors(self):
107        ancestors = []
108        parent = self.parent
109        while parent:
110            ancestors.append(parent)
111            parent = parent.parent
112
113        return ancestors
114
115    def self_and_ancestors(self):
116        return [self] + self.ancestors()
117
118    def rpaths(self):
119        return self._rpaths
120
121    def all_rpaths(self):
122        rpaths = []
123        for image in self.self_and_ancestors():
124            rpaths.extend(image.rpaths())
125        return rpaths
126
127    def root(self):
128        if not self.parent:
129            return self
130        return self.ancestors()[-1]
131
132    def executable_path(self):
133        root = self.root()
134        if root.is_executable():
135            return root.image_path
136        return None
137
138    def filetype(self):
139        return long(self.header_info.get('filetype', 0))
140
141    def is_dylib(self):
142        return self.filetype() == MachOFile.MH_DYLIB
143
144    def is_executable(self):
145        return self.filetype() == MachOFile.MH_EXECUTE
146
147    def all_dependencies(self):
148        self.walk_dependencies()
149        return self.cache()['order']
150
151    def walk_dependencies(self, known = {}):
152        if known.get(self.image_path.resolved_path):
153            return
154
155        known[self.image_path.resolved_path] = self
156
157        for item in self.dependencies():
158            item.walk_dependencies(known)
159
160    def dependencies(self):
161        if not self.image_path.exists():
162            return []
163
164        if self._dependencies:
165            return self._dependencies
166
167        output = self.shell('otool -arch {0} -L "{1}"',
168            [self.arch, self.image_path.resolved_path], fatal = True)
169        output = [line.strip() for line in output.splitlines()]
170        del(output[0])
171        if self.is_dylib():
172            # In the case of dylibs, the first line is the id line
173            del(output[0])
174
175        self._dependencies = []
176        for line in output:
177            match = re.match('^(.+)\s+(\(.+)\)$', line)
178            if not match:
179                continue
180            recorded_path = match.group(1)
181            image_path = self.image_path_for_recorded_path(recorded_path)
182            image = self.lookup_or_make_item(image_path)
183            self._dependencies.append(image)
184
185        return self._dependencies
186
187    # The root item holds the cache, all lower-level requests bubble up
188    # the parent chain
189    def cache(self):
190        if self.parent:
191            return self.parent.cache()
192        return self._cache
193
194    def add_to_cache(self):
195        cache = self.cache()
196        cache['paths'][self.image_path.resolved_path] = self
197        cache['order'].append(self)
198
199    def cached_item_for_path(self, path):
200        if not path:
201            return None
202        return self.cache()['paths'].get(path)
203
204    def lookup_or_make_item(self, image_path):
205        image = self.cached_item_for_path(image_path.resolved_path)
206        if not image: # cache miss
207            image = MachOFile(image_path, self.arch, parent = self)
208        return image
209
210    def image_path_for_recorded_path(self, recorded_path):
211        path = ImagePath(None, recorded_path)
212
213        # handle @executable_path
214        if recorded_path.startswith(ImagePath.EXECUTABLE_PATH_TOKEN):
215            executable_image_path = self.executable_path()
216            if executable_image_path:
217                path.resolved_path = os.path.normpath(
218                    recorded_path.replace(
219                        ImagePath.EXECUTABLE_PATH_TOKEN,
220                        os.path.dirname(executable_image_path.resolved_path)))
221
222        # handle @loader_path
223        elif recorded_path.startswith(ImagePath.LOADER_PATH_TOKEN):
224            path.resolved_path = os.path.normpath(recorded_path.replace(
225                ImagePath.LOADER_PATH_TOKEN,
226                os.path.dirname(self.image_path.resolved_path)))
227
228        # handle @rpath
229        elif recorded_path.startswith(ImagePath.RPATH_TOKEN):
230            for rpath in self.all_rpaths():
231                resolved_path = os.path.normpath(recorded_path.replace(
232                    ImagePath.RPATH_TOKEN, rpath.resolved_path))
233                if os.path.exists(resolved_path):
234                    path.resolved_path = resolved_path
235                    path.rpath_source = rpath.rpath_source
236                    break
237
238        # handle absolute path
239        elif recorded_path.startswith('/'):
240            path.resolved_path = recorded_path
241
242        return path
243
244    def __repr__(self):
245        return str(self.image_path)
246
247    def dump(self):
248        print(self.image_path)
249        for dependency in self.dependencies():
250            print('\t{0}'.format(dependency))
251
252    @staticmethod
253    def shell(cmd_format, args, fatal = False):
254        cmd = cmd_format.format(*args)
255        popen = subprocess.Popen(cmd, shell = True, stdout = subprocess.PIPE)
256        output = popen.communicate()[0]
257        if popen.returncode and fatal:
258            print("Nonzero exit status for shell command '{}'".format(cmd),
259                file=sys.stderr)
260            sys.exit(1)
261
262        return output
263
264    @classmethod
265    def architectures_for_image_at_path(cls, path):
266        output = cls.shell('file "{}"', [path])
267        file_architectures = re.findall(r' executable (\w+)', output)
268        ordering = 'x86_64 i386'.split()
269        file_architectures = sorted(file_architectures, lambda a, b: cmp(
270            ordering.index(a), ordering.index(b)))
271        return file_architectures
272
273    MH_EXECUTE = 0x2
274    MH_DYLIB = 0x6
275    MH_BUNDLE = 0x8
276
277
278# ANSI terminal coloring sequences
279class Color:
280    HEADER = '\033[95m'
281    BLUE = '\033[94m'
282    GREEN = '\033[92m'
283    RED = '\033[91m'
284    ENDC = '\033[0m'
285
286    @staticmethod
287    def red(string):
288        return Color.wrap(string, Color.RED)
289
290    @staticmethod
291    def blue(string):
292        return Color.wrap(string, Color.BLUE)
293
294    @staticmethod
295    def wrap(string, color):
296        return Color.HEADER + color + string + Color.ENDC
297
298
299# This class holds path information for a mach-0 image file.
300# It holds the path as it was recorded in the loading binary as well as
301# the effective, resolved file system path.
302# The former can contain @-replacement tokens.
303# In the case where the recorded path contains an @rpath token that was
304# resolved successfully, we also capture the path of the binary that
305# supplied the rpath value that was used.
306# That path itself can contain replacement tokens such as @loader_path.
307class ImagePath:
308
309    def __init__(self, resolved_path, recorded_path = None):
310        self.recorded_path = recorded_path
311        self.resolved_path = resolved_path
312        self.rpath_source = None
313
314    def __repr__(self):
315        description = None
316
317        if self.resolved_equals_recorded() or self.recorded_path == None:
318            description = self.resolved_path
319        else:
320            description = '{0} ({1})'.format(self.resolved_path,
321                self.recorded_path)
322
323        if (not self.is_system_location()) and (not self.uses_dyld_token()):
324            description = Color.blue(description)
325
326        if self.rpath_source:
327            description += ' (rpath source: {0})'.format(
328                self.rpath_source.image_path.resolved_path)
329
330        if not self.exists():
331            description += Color.red(' (missing)')
332
333        return description
334
335    def exists(self):
336        return self.resolved_path and os.path.exists(self.resolved_path)
337
338    def resolved_equals_recorded(self):
339        return (self.resolved_path and self.recorded_path and
340            self.resolved_path == self.recorded_path)
341
342    def uses_dyld_token(self):
343        return self.recorded_path and self.recorded_path.startswith('@')
344
345    def is_system_location(self):
346        system_prefixes = ['/System/Library', '/usr/lib']
347        for prefix in system_prefixes:
348            if self.resolved_path and self.resolved_path.startswith(prefix):
349                return True
350
351    EXECUTABLE_PATH_TOKEN = '@executable_path'
352    LOADER_PATH_TOKEN = '@loader_path'
353    RPATH_TOKEN = '@rpath'
354
355
356# Command line driver
357parser = optparse.OptionParser(
358    usage = "Usage: %prog [options] path_to_mach_o_file")
359parser.add_option(
360    "--arch", dest = "arch", help = "architecture", metavar = "ARCH")
361parser.add_option(
362    "--all", dest = "include_system_libraries",
363    help = "Include system frameworks and libraries", action="store_true")
364(options, args) = parser.parse_args()
365
366if len(args) < 1:
367    parser.print_help()
368    sys.exit(1)
369
370archs = MachOFile.architectures_for_image_at_path(args[0])
371if archs and not options.arch:
372    print('Analyzing architecture {}, override with --arch if needed'.format(
373        archs[0]), file=sys.stderr)
374    options.arch = archs[0]
375
376toplevel_image = MachOFile(ImagePath(args[0]), options.arch)
377
378for dependency in toplevel_image.all_dependencies():
379    if (dependency.image_path.exists() and
380            (not options.include_system_libraries) and
381            dependency.image_path.is_system_location()):
382        continue
383
384    dependency.dump()
385    print("\n")
386
387