1# -*- coding: utf-8 -*-
2"""The Apple Framework builds require their own customization"""
3import logging
4import os
5import struct
6import subprocess
7from abc import ABCMeta, abstractmethod
8from textwrap import dedent
9
10from six import add_metaclass
11
12from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest, RefMust
13from virtualenv.util.path import Path
14from virtualenv.util.six import ensure_text
15
16from .common import CPython, CPythonPosix, is_mac_os_framework
17from .cpython2 import CPython2PosixBase
18from .cpython3 import CPython3
19
20
21@add_metaclass(ABCMeta)
22class CPythonmacOsFramework(CPython):
23    @classmethod
24    def can_describe(cls, interpreter):
25        return is_mac_os_framework(interpreter) and super(CPythonmacOsFramework, cls).can_describe(interpreter)
26
27    @classmethod
28    def sources(cls, interpreter):
29        for src in super(CPythonmacOsFramework, cls).sources(interpreter):
30            yield src
31        # add a symlink to the host python image
32        exe = cls.image_ref(interpreter)
33        ref = PathRefToDest(exe, dest=lambda self, _: self.dest / ".Python", must=RefMust.SYMLINK)
34        yield ref
35
36    def create(self):
37        super(CPythonmacOsFramework, self).create()
38
39        # change the install_name of the copied python executables
40        target = "@executable_path/../.Python"
41        current = self.current_mach_o_image_path()
42        for src in self._sources:
43            if isinstance(src, ExePathRefToDest):
44                if src.must == RefMust.COPY or not self.symlinks:
45                    exes = [self.bin_dir / src.base]
46                    if not self.symlinks:
47                        exes.extend(self.bin_dir / a for a in src.aliases)
48                    for exe in exes:
49                        fix_mach_o(str(exe), current, target, self.interpreter.max_size)
50
51    @classmethod
52    def _executables(cls, interpreter):
53        for _, targets, must, when in super(CPythonmacOsFramework, cls)._executables(interpreter):
54            # Make sure we use the embedded interpreter inside the framework, even if sys.executable points to the
55            # stub executable in ${sys.prefix}/bin.
56            # See http://groups.google.com/group/python-virtualenv/browse_thread/thread/17cab2f85da75951
57            fixed_host_exe = Path(interpreter.prefix) / "Resources" / "Python.app" / "Contents" / "MacOS" / "Python"
58            yield fixed_host_exe, targets, must, when
59
60    @abstractmethod
61    def current_mach_o_image_path(self):
62        raise NotImplementedError
63
64    @classmethod
65    def image_ref(cls, interpreter):
66        raise NotImplementedError
67
68
69class CPython2macOsFramework(CPythonmacOsFramework, CPython2PosixBase):
70    @classmethod
71    def image_ref(cls, interpreter):
72        return Path(interpreter.prefix) / "Python"
73
74    def current_mach_o_image_path(self):
75        return os.path.join(self.interpreter.prefix, "Python")
76
77    @classmethod
78    def sources(cls, interpreter):
79        for src in super(CPython2macOsFramework, cls).sources(interpreter):
80            yield src
81        # landmark for exec_prefix
82        exec_marker_file, to_path, _ = cls.from_stdlib(cls.mappings(interpreter), "lib-dynload")
83        yield PathRefToDest(exec_marker_file, dest=to_path)
84
85    @property
86    def reload_code(self):
87        result = super(CPython2macOsFramework, self).reload_code
88        result = dedent(
89            """
90        # the bundled site.py always adds the global site package if we're on python framework build, escape this
91        import sysconfig
92        config = sysconfig.get_config_vars()
93        before = config["PYTHONFRAMEWORK"]
94        try:
95            config["PYTHONFRAMEWORK"] = ""
96            {}
97        finally:
98            config["PYTHONFRAMEWORK"] = before
99        """.format(
100                result,
101            ),
102        )
103        return result
104
105
106class CPython3macOsFramework(CPythonmacOsFramework, CPython3, CPythonPosix):
107    @classmethod
108    def image_ref(cls, interpreter):
109        return Path(interpreter.prefix) / "Python3"
110
111    def current_mach_o_image_path(self):
112        return "@executable_path/../../../../Python3"
113
114    @property
115    def reload_code(self):
116        result = super(CPython3macOsFramework, self).reload_code
117        result = dedent(
118            """
119        # the bundled site.py always adds the global site package if we're on python framework build, escape this
120        import sys
121        before = sys._framework
122        try:
123            sys._framework = None
124            {}
125        finally:
126            sys._framework = before
127        """.format(
128                result,
129            ),
130        )
131        return result
132
133
134def fix_mach_o(exe, current, new, max_size):
135    """
136    https://en.wikipedia.org/wiki/Mach-O
137
138    Mach-O, short for Mach object file format, is a file format for executables, object code, shared libraries,
139    dynamically-loaded code, and core dumps. A replacement for the a.out format, Mach-O offers more extensibility and
140    faster access to information in the symbol table.
141
142    Each Mach-O file is made up of one Mach-O header, followed by a series of load commands, followed by one or more
143    segments, each of which contains between 0 and 255 sections. Mach-O uses the REL relocation format to handle
144    references to symbols. When looking up symbols Mach-O uses a two-level namespace that encodes each symbol into an
145    'object/symbol name' pair that is then linearly searched for by first the object and then the symbol name.
146
147    The basic structure—a list of variable-length "load commands" that reference pages of data elsewhere in the file—was
148    also used in the executable file format for Accent. The Accent file format was in turn, based on an idea from Spice
149    Lisp.
150
151    With the introduction of Mac OS X 10.6 platform the Mach-O file underwent a significant modification that causes
152    binaries compiled on a computer running 10.6 or later to be (by default) executable only on computers running Mac
153    OS X 10.6 or later. The difference stems from load commands that the dynamic linker, in previous Mac OS X versions,
154    does not understand. Another significant change to the Mach-O format is the change in how the Link Edit tables
155    (found in the __LINKEDIT section) function. In 10.6 these new Link Edit tables are compressed by removing unused and
156    unneeded bits of information, however Mac OS X 10.5 and earlier cannot read this new Link Edit table format.
157    """
158    try:
159        logging.debug(u"change Mach-O for %s from %s to %s", ensure_text(exe), current, ensure_text(new))
160        _builtin_change_mach_o(max_size)(exe, current, new)
161    except Exception as e:
162        logging.warning("Could not call _builtin_change_mac_o: %s. " "Trying to call install_name_tool instead.", e)
163        try:
164            cmd = ["install_name_tool", "-change", current, new, exe]
165            subprocess.check_call(cmd)
166        except Exception:
167            logging.fatal("Could not call install_name_tool -- you must " "have Apple's development tools installed")
168            raise
169
170
171def _builtin_change_mach_o(maxint):
172    MH_MAGIC = 0xFEEDFACE
173    MH_CIGAM = 0xCEFAEDFE
174    MH_MAGIC_64 = 0xFEEDFACF
175    MH_CIGAM_64 = 0xCFFAEDFE
176    FAT_MAGIC = 0xCAFEBABE
177    BIG_ENDIAN = ">"
178    LITTLE_ENDIAN = "<"
179    LC_LOAD_DYLIB = 0xC
180
181    class FileView(object):
182        """A proxy for file-like objects that exposes a given view of a file. Modified from macholib."""
183
184        def __init__(self, file_obj, start=0, size=maxint):
185            if isinstance(file_obj, FileView):
186                self._file_obj = file_obj._file_obj
187            else:
188                self._file_obj = file_obj
189            self._start = start
190            self._end = start + size
191            self._pos = 0
192
193        def __repr__(self):
194            return "<fileview [{:d}, {:d}] {!r}>".format(self._start, self._end, self._file_obj)
195
196        def tell(self):
197            return self._pos
198
199        def _checkwindow(self, seek_to, op):
200            if not (self._start <= seek_to <= self._end):
201                msg = "{} to offset {:d} is outside window [{:d}, {:d}]".format(op, seek_to, self._start, self._end)
202                raise IOError(msg)
203
204        def seek(self, offset, whence=0):
205            seek_to = offset
206            if whence == os.SEEK_SET:
207                seek_to += self._start
208            elif whence == os.SEEK_CUR:
209                seek_to += self._start + self._pos
210            elif whence == os.SEEK_END:
211                seek_to += self._end
212            else:
213                raise IOError("Invalid whence argument to seek: {!r}".format(whence))
214            self._checkwindow(seek_to, "seek")
215            self._file_obj.seek(seek_to)
216            self._pos = seek_to - self._start
217
218        def write(self, content):
219            here = self._start + self._pos
220            self._checkwindow(here, "write")
221            self._checkwindow(here + len(content), "write")
222            self._file_obj.seek(here, os.SEEK_SET)
223            self._file_obj.write(content)
224            self._pos += len(content)
225
226        def read(self, size=maxint):
227            assert size >= 0
228            here = self._start + self._pos
229            self._checkwindow(here, "read")
230            size = min(size, self._end - here)
231            self._file_obj.seek(here, os.SEEK_SET)
232            read_bytes = self._file_obj.read(size)
233            self._pos += len(read_bytes)
234            return read_bytes
235
236    def read_data(file, endian, num=1):
237        """Read a given number of 32-bits unsigned integers from the given file with the given endianness."""
238        res = struct.unpack(endian + "L" * num, file.read(num * 4))
239        if len(res) == 1:
240            return res[0]
241        return res
242
243    def mach_o_change(at_path, what, value):
244        """Replace a given name (what) in any LC_LOAD_DYLIB command found in the given binary with a new name (value),
245        provided it's shorter."""
246
247        def do_macho(file, bits, endian):
248            # Read Mach-O header (the magic number is assumed read by the caller)
249            cpu_type, cpu_sub_type, file_type, n_commands, size_of_commands, flags = read_data(file, endian, 6)
250            # 64-bits header has one more field.
251            if bits == 64:
252                read_data(file, endian)
253            # The header is followed by n commands
254            for _ in range(n_commands):
255                where = file.tell()
256                # Read command header
257                cmd, cmd_size = read_data(file, endian, 2)
258                if cmd == LC_LOAD_DYLIB:
259                    # The first data field in LC_LOAD_DYLIB commands is the offset of the name, starting from the
260                    # beginning of the  command.
261                    name_offset = read_data(file, endian)
262                    file.seek(where + name_offset, os.SEEK_SET)
263                    # Read the NUL terminated string
264                    load = file.read(cmd_size - name_offset).decode()
265                    load = load[: load.index("\0")]
266                    # If the string is what is being replaced, overwrite it.
267                    if load == what:
268                        file.seek(where + name_offset, os.SEEK_SET)
269                        file.write(value.encode() + b"\0")
270                # Seek to the next command
271                file.seek(where + cmd_size, os.SEEK_SET)
272
273        def do_file(file, offset=0, size=maxint):
274            file = FileView(file, offset, size)
275            # Read magic number
276            magic = read_data(file, BIG_ENDIAN)
277            if magic == FAT_MAGIC:
278                # Fat binaries contain nfat_arch Mach-O binaries
279                n_fat_arch = read_data(file, BIG_ENDIAN)
280                for _ in range(n_fat_arch):
281                    # Read arch header
282                    cpu_type, cpu_sub_type, offset, size, align = read_data(file, BIG_ENDIAN, 5)
283                    do_file(file, offset, size)
284            elif magic == MH_MAGIC:
285                do_macho(file, 32, BIG_ENDIAN)
286            elif magic == MH_CIGAM:
287                do_macho(file, 32, LITTLE_ENDIAN)
288            elif magic == MH_MAGIC_64:
289                do_macho(file, 64, BIG_ENDIAN)
290            elif magic == MH_CIGAM_64:
291                do_macho(file, 64, LITTLE_ENDIAN)
292
293        assert len(what) >= len(value)
294
295        with open(at_path, "r+b") as f:
296            do_file(f)
297
298    return mach_o_change
299