1# Copyright 2013-2014 The Meson development team
2
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6
7#     http://www.apache.org/licenses/LICENSE-2.0
8
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import sys, pickle, os, shutil, subprocess, errno
16import argparse
17import shlex
18from glob import glob
19from .scripts import depfixer
20from .scripts import destdir_join
21from .mesonlib import is_windows, Popen_safe
22from .mtest import rebuild_all
23try:
24    from __main__ import __file__ as main_file
25except ImportError:
26    # Happens when running as meson.exe which is native Windows.
27    # This is only used for pkexec which is not, so this is fine.
28    main_file = None
29
30symlink_warning = '''Warning: trying to copy a symlink that points to a file. This will copy the file,
31but this will be changed in a future version of Meson to copy the symlink as is. Please update your
32build definitions so that it will not break when the change happens.'''
33
34selinux_updates = []
35
36def add_arguments(parser):
37    parser.add_argument('-C', default='.', dest='wd',
38                        help='directory to cd into before running')
39    parser.add_argument('--profile-self', action='store_true', dest='profile',
40                        help=argparse.SUPPRESS)
41    parser.add_argument('--no-rebuild', default=False, action='store_true',
42                        help='Do not rebuild before installing.')
43    parser.add_argument('--only-changed', default=False, action='store_true',
44                        help='Only overwrite files that are older than the copied file.')
45    parser.add_argument('--quiet', default=False, action='store_true',
46                        help='Do not print every file that was installed.')
47
48class DirMaker:
49    def __init__(self, lf):
50        self.lf = lf
51        self.dirs = []
52
53    def makedirs(self, path, exist_ok=False):
54        dirname = os.path.normpath(path)
55        dirs = []
56        while dirname != os.path.dirname(dirname):
57            if not os.path.exists(dirname):
58                dirs.append(dirname)
59            dirname = os.path.dirname(dirname)
60        os.makedirs(path, exist_ok=exist_ok)
61
62        # store the directories in creation order, with the parent directory
63        # before the child directories. Future calls of makedir() will not
64        # create the parent directories, so the last element in the list is
65        # the last one to be created. That is the first one to be removed on
66        # __exit__
67        dirs.reverse()
68        self.dirs += dirs
69
70    def __enter__(self):
71        return self
72
73    def __exit__(self, exception_type, value, traceback):
74        self.dirs.reverse()
75        for d in self.dirs:
76            append_to_log(self.lf, d)
77
78def is_executable(path, follow_symlinks=False):
79    '''Checks whether any of the "x" bits are set in the source file mode.'''
80    return bool(os.stat(path, follow_symlinks=follow_symlinks).st_mode & 0o111)
81
82def append_to_log(lf, line):
83    lf.write(line)
84    if not line.endswith('\n'):
85        lf.write('\n')
86    lf.flush()
87
88def set_chown(path, user=None, group=None, dir_fd=None, follow_symlinks=True):
89    # shutil.chown will call os.chown without passing all the parameters
90    # and particularly follow_symlinks, thus we replace it temporary
91    # with a lambda with all the parameters so that follow_symlinks will
92    # be actually passed properly.
93    # Not nice, but better than actually rewriting shutil.chown until
94    # this python bug is fixed: https://bugs.python.org/issue18108
95    real_os_chown = os.chown
96    try:
97        os.chown = lambda p, u, g: real_os_chown(p, u, g,
98                                                 dir_fd=dir_fd,
99                                                 follow_symlinks=follow_symlinks)
100        shutil.chown(path, user, group)
101    except Exception:
102        raise
103    finally:
104        os.chown = real_os_chown
105
106def set_chmod(path, mode, dir_fd=None, follow_symlinks=True):
107    try:
108        os.chmod(path, mode, dir_fd=dir_fd, follow_symlinks=follow_symlinks)
109    except (NotImplementedError, OSError, SystemError):
110        if not os.path.islink(path):
111            os.chmod(path, mode, dir_fd=dir_fd)
112
113def sanitize_permissions(path, umask):
114    if umask == 'preserve':
115        return
116    new_perms = 0o777 if is_executable(path, follow_symlinks=False) else 0o666
117    new_perms &= ~umask
118    try:
119        set_chmod(path, new_perms, follow_symlinks=False)
120    except PermissionError as e:
121        msg = '{!r}: Unable to set permissions {!r}: {}, ignoring...'
122        print(msg.format(path, new_perms, e.strerror))
123
124def set_mode(path, mode, default_umask):
125    if mode is None or (mode.perms_s or mode.owner or mode.group) is None:
126        # Just sanitize permissions with the default umask
127        sanitize_permissions(path, default_umask)
128        return
129    # No chown() on Windows, and must set one of owner/group
130    if not is_windows() and (mode.owner or mode.group) is not None:
131        try:
132            set_chown(path, mode.owner, mode.group, follow_symlinks=False)
133        except PermissionError as e:
134            msg = '{!r}: Unable to set owner {!r} and group {!r}: {}, ignoring...'
135            print(msg.format(path, mode.owner, mode.group, e.strerror))
136        except LookupError:
137            msg = '{!r}: Non-existent owner {!r} or group {!r}: ignoring...'
138            print(msg.format(path, mode.owner, mode.group))
139        except OSError as e:
140            if e.errno == errno.EINVAL:
141                msg = '{!r}: Non-existent numeric owner {!r} or group {!r}: ignoring...'
142                print(msg.format(path, mode.owner, mode.group))
143            else:
144                raise
145    # Must set permissions *after* setting owner/group otherwise the
146    # setuid/setgid bits will get wiped by chmod
147    # NOTE: On Windows you can set read/write perms; the rest are ignored
148    if mode.perms_s is not None:
149        try:
150            set_chmod(path, mode.perms, follow_symlinks=False)
151        except PermissionError as e:
152            msg = '{!r}: Unable to set permissions {!r}: {}, ignoring...'
153            print(msg.format(path, mode.perms_s, e.strerror))
154    else:
155        sanitize_permissions(path, default_umask)
156
157def restore_selinux_contexts():
158    '''
159    Restores the SELinux context for files in @selinux_updates
160
161    If $DESTDIR is set, do not warn if the call fails.
162    '''
163    try:
164        subprocess.check_call(['selinuxenabled'])
165    except (FileNotFoundError, NotADirectoryError, PermissionError, subprocess.CalledProcessError):
166        # If we don't have selinux or selinuxenabled returned 1, failure
167        # is ignored quietly.
168        return
169
170    if not shutil.which('restorecon'):
171        # If we don't have restorecon, failure is ignored quietly.
172        return
173
174    if not selinux_updates:
175        # If the list of files is empty, do not try to call restorecon.
176        return
177
178    with subprocess.Popen(['restorecon', '-F', '-f-', '-0'],
179                          stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc:
180        out, err = proc.communicate(input=b'\0'.join(os.fsencode(f) for f in selinux_updates) + b'\0')
181        if proc.returncode != 0 and not os.environ.get('DESTDIR'):
182            print('Failed to restore SELinux context of installed files...',
183                  'Standard output:', out.decode(),
184                  'Standard error:', err.decode(), sep='\n')
185
186
187def get_destdir_path(d, path):
188    if os.path.isabs(path):
189        output = destdir_join(d.destdir, path)
190    else:
191        output = os.path.join(d.fullprefix, path)
192    return output
193
194
195def check_for_stampfile(fname):
196    '''Some languages e.g. Rust have output files
197    whose names are not known at configure time.
198    Check if this is the case and return the real
199    file instead.'''
200    if fname.endswith('.so') or fname.endswith('.dll'):
201        if os.stat(fname).st_size == 0:
202            (base, suffix) = os.path.splitext(fname)
203            files = glob(base + '-*' + suffix)
204            if len(files) > 1:
205                print("Stale dynamic library files in build dir. Can't install.")
206                sys.exit(1)
207            if len(files) == 1:
208                return files[0]
209    elif fname.endswith('.a') or fname.endswith('.lib'):
210        if os.stat(fname).st_size == 0:
211            (base, suffix) = os.path.splitext(fname)
212            files = glob(base + '-*' + '.rlib')
213            if len(files) > 1:
214                print("Stale static library files in build dir. Can't install.")
215                sys.exit(1)
216            if len(files) == 1:
217                return files[0]
218    return fname
219
220class Installer:
221
222    def __init__(self, options, lf):
223        self.did_install_something = False
224        self.options = options
225        self.lf = lf
226        self.preserved_file_count = 0
227
228    def log(self, msg):
229        if not self.options.quiet:
230            print(msg)
231
232    def should_preserve_existing_file(self, from_file, to_file):
233        if not self.options.only_changed:
234            return False
235        # Always replace danging symlinks
236        if os.path.islink(from_file) and not os.path.isfile(from_file):
237            return False
238        from_time = os.stat(from_file).st_mtime
239        to_time = os.stat(to_file).st_mtime
240        return from_time <= to_time
241
242    def do_copyfile(self, from_file, to_file, makedirs=None):
243        outdir = os.path.split(to_file)[0]
244        if not os.path.isfile(from_file) and not os.path.islink(from_file):
245            raise RuntimeError('Tried to install something that isn\'t a file:'
246                               '{!r}'.format(from_file))
247        # copyfile fails if the target file already exists, so remove it to
248        # allow overwriting a previous install. If the target is not a file, we
249        # want to give a readable error.
250        if os.path.exists(to_file):
251            if not os.path.isfile(to_file):
252                raise RuntimeError('Destination {!r} already exists and is not '
253                                   'a file'.format(to_file))
254            if self.should_preserve_existing_file(from_file, to_file):
255                append_to_log(self.lf, '# Preserving old file {}\n'.format(to_file))
256                self.preserved_file_count += 1
257                return False
258            os.remove(to_file)
259        elif makedirs:
260            # Unpack tuple
261            dirmaker, outdir = makedirs
262            # Create dirs if needed
263            dirmaker.makedirs(outdir, exist_ok=True)
264        self.log('Installing {} to {}'.format(from_file, outdir))
265        if os.path.islink(from_file):
266            if not os.path.exists(from_file):
267                # Dangling symlink. Replicate as is.
268                shutil.copy(from_file, outdir, follow_symlinks=False)
269            else:
270                # Remove this entire branch when changing the behaviour to duplicate
271                # symlinks rather than copying what they point to.
272                print(symlink_warning)
273                shutil.copyfile(from_file, to_file)
274                shutil.copystat(from_file, to_file)
275        else:
276            shutil.copyfile(from_file, to_file)
277            shutil.copystat(from_file, to_file)
278        selinux_updates.append(to_file)
279        append_to_log(self.lf, to_file)
280        return True
281
282    def do_copydir(self, data, src_dir, dst_dir, exclude, install_mode):
283        '''
284        Copies the contents of directory @src_dir into @dst_dir.
285
286        For directory
287            /foo/
288              bar/
289                excluded
290                foobar
291              file
292        do_copydir(..., '/foo', '/dst/dir', {'bar/excluded'}) creates
293            /dst/
294              dir/
295                bar/
296                  foobar
297                file
298
299        Args:
300            src_dir: str, absolute path to the source directory
301            dst_dir: str, absolute path to the destination directory
302            exclude: (set(str), set(str)), tuple of (exclude_files, exclude_dirs),
303                     each element of the set is a path relative to src_dir.
304        '''
305        if not os.path.isabs(src_dir):
306            raise ValueError('src_dir must be absolute, got {}'.format(src_dir))
307        if not os.path.isabs(dst_dir):
308            raise ValueError('dst_dir must be absolute, got {}'.format(dst_dir))
309        if exclude is not None:
310            exclude_files, exclude_dirs = exclude
311        else:
312            exclude_files = exclude_dirs = set()
313        for root, dirs, files in os.walk(src_dir):
314            assert os.path.isabs(root)
315            for d in dirs[:]:
316                abs_src = os.path.join(root, d)
317                filepart = os.path.relpath(abs_src, start=src_dir)
318                abs_dst = os.path.join(dst_dir, filepart)
319                # Remove these so they aren't visited by os.walk at all.
320                if filepart in exclude_dirs:
321                    dirs.remove(d)
322                    continue
323                if os.path.isdir(abs_dst):
324                    continue
325                if os.path.exists(abs_dst):
326                    print('Tried to copy directory {} but a file of that name already exists.'.format(abs_dst))
327                    sys.exit(1)
328                data.dirmaker.makedirs(abs_dst)
329                shutil.copystat(abs_src, abs_dst)
330                sanitize_permissions(abs_dst, data.install_umask)
331            for f in files:
332                abs_src = os.path.join(root, f)
333                filepart = os.path.relpath(abs_src, start=src_dir)
334                if filepart in exclude_files:
335                    continue
336                abs_dst = os.path.join(dst_dir, filepart)
337                if os.path.isdir(abs_dst):
338                    print('Tried to copy file {} but a directory of that name already exists.'.format(abs_dst))
339                    sys.exit(1)
340                parent_dir = os.path.dirname(abs_dst)
341                if not os.path.isdir(parent_dir):
342                    os.mkdir(parent_dir)
343                    shutil.copystat(os.path.dirname(abs_src), parent_dir)
344                # FIXME: what about symlinks?
345                self.do_copyfile(abs_src, abs_dst)
346                set_mode(abs_dst, install_mode, data.install_umask)
347
348    def do_install(self, datafilename):
349        with open(datafilename, 'rb') as ifile:
350            d = pickle.load(ifile)
351        d.destdir = os.environ.get('DESTDIR', '')
352        d.fullprefix = destdir_join(d.destdir, d.prefix)
353
354        if d.install_umask != 'preserve':
355            os.umask(d.install_umask)
356
357        self.did_install_something = False
358        try:
359            d.dirmaker = DirMaker(self.lf)
360            with d.dirmaker:
361                self.install_subdirs(d) # Must be first, because it needs to delete the old subtree.
362                self.install_targets(d)
363                self.install_headers(d)
364                self.install_man(d)
365                self.install_data(d)
366                restore_selinux_contexts()
367                self.run_install_script(d)
368                if not self.did_install_something:
369                    self.log('Nothing to install.')
370                if not self.options.quiet and self.preserved_file_count > 0:
371                    self.log('Preserved {} unchanged files, see {} for the full list'
372                             .format(self.preserved_file_count, os.path.normpath(self.lf.name)))
373        except PermissionError:
374            if shutil.which('pkexec') is not None and 'PKEXEC_UID' not in os.environ:
375                print('Installation failed due to insufficient permissions.')
376                print('Attempting to use polkit to gain elevated privileges...')
377                os.execlp('pkexec', 'pkexec', sys.executable, main_file, *sys.argv[1:],
378                          '-C', os.getcwd())
379            else:
380                raise
381
382    def install_subdirs(self, d):
383        for (src_dir, dst_dir, mode, exclude) in d.install_subdirs:
384            self.did_install_something = True
385            full_dst_dir = get_destdir_path(d, dst_dir)
386            self.log('Installing subdir {} to {}'.format(src_dir, full_dst_dir))
387            d.dirmaker.makedirs(full_dst_dir, exist_ok=True)
388            self.do_copydir(d, src_dir, full_dst_dir, exclude, mode)
389
390    def install_data(self, d):
391        for i in d.data:
392            fullfilename = i[0]
393            outfilename = get_destdir_path(d, i[1])
394            mode = i[2]
395            outdir = os.path.dirname(outfilename)
396            if self.do_copyfile(fullfilename, outfilename, makedirs=(d.dirmaker, outdir)):
397                self.did_install_something = True
398            set_mode(outfilename, mode, d.install_umask)
399
400    def install_man(self, d):
401        for m in d.man:
402            full_source_filename = m[0]
403            outfilename = get_destdir_path(d, m[1])
404            outdir = os.path.dirname(outfilename)
405            install_mode = m[2]
406            if self.do_copyfile(full_source_filename, outfilename, makedirs=(d.dirmaker, outdir)):
407                self.did_install_something = True
408            set_mode(outfilename, install_mode, d.install_umask)
409
410    def install_headers(self, d):
411        for t in d.headers:
412            fullfilename = t[0]
413            fname = os.path.basename(fullfilename)
414            outdir = get_destdir_path(d, t[1])
415            outfilename = os.path.join(outdir, fname)
416            install_mode = t[2]
417            if self.do_copyfile(fullfilename, outfilename, makedirs=(d.dirmaker, outdir)):
418                self.did_install_something = True
419            set_mode(outfilename, install_mode, d.install_umask)
420
421    def run_install_script(self, d):
422        env = {'MESON_SOURCE_ROOT': d.source_dir,
423               'MESON_BUILD_ROOT': d.build_dir,
424               'MESON_INSTALL_PREFIX': d.prefix,
425               'MESON_INSTALL_DESTDIR_PREFIX': d.fullprefix,
426               'MESONINTROSPECT': ' '.join([shlex.quote(x) for x in d.mesonintrospect]),
427               }
428        if self.options.quiet:
429            env['MESON_INSTALL_QUIET'] = '1'
430
431        child_env = os.environ.copy()
432        child_env.update(env)
433
434        for i in d.install_scripts:
435            self.did_install_something = True  # Custom script must report itself if it does nothing.
436            script = i['exe']
437            args = i['args']
438            name = ' '.join(script + args)
439            self.log('Running custom install script {!r}'.format(name))
440            try:
441                rc = subprocess.call(script + args, env=child_env)
442            except OSError:
443                print('FAILED: install script \'{}\' could not be run, stopped'.format(name))
444                # POSIX shells return 127 when a command could not be found
445                sys.exit(127)
446            if rc != 0:
447                print('FAILED: install script \'{}\' exit code {}, stopped'.format(name, rc))
448                sys.exit(rc)
449
450    def install_targets(self, d):
451        for t in d.targets:
452            if not os.path.exists(t.fname):
453                # For example, import libraries of shared modules are optional
454                if t.optional:
455                    self.log('File {!r} not found, skipping'.format(t.fname))
456                    continue
457                else:
458                    raise RuntimeError('File {!r} could not be found'.format(t.fname))
459            file_copied = False # not set when a directory is copied
460            fname = check_for_stampfile(t.fname)
461            outdir = get_destdir_path(d, t.outdir)
462            outname = os.path.join(outdir, os.path.basename(fname))
463            final_path = os.path.join(d.prefix, t.outdir, os.path.basename(fname))
464            aliases = t.aliases
465            should_strip = t.strip
466            install_rpath = t.install_rpath
467            install_name_mappings = t.install_name_mappings
468            install_mode = t.install_mode
469            if not os.path.exists(fname):
470                raise RuntimeError('File {!r} could not be found'.format(fname))
471            elif os.path.isfile(fname):
472                file_copied = self.do_copyfile(fname, outname, makedirs=(d.dirmaker, outdir))
473                set_mode(outname, install_mode, d.install_umask)
474                if should_strip and d.strip_bin is not None:
475                    if fname.endswith('.jar'):
476                        self.log('Not stripping jar target:', os.path.basename(fname))
477                        continue
478                    self.log('Stripping target {!r} using {}.'.format(fname, d.strip_bin[0]))
479                    ps, stdo, stde = Popen_safe(d.strip_bin + [outname])
480                    if ps.returncode != 0:
481                        print('Could not strip file.\n')
482                        print('Stdout:\n{}\n'.format(stdo))
483                        print('Stderr:\n{}\n'.format(stde))
484                        sys.exit(1)
485                if fname.endswith('.js'):
486                    # Emscripten outputs js files and optionally a wasm file.
487                    # If one was generated, install it as well.
488                    wasm_source = os.path.splitext(fname)[0] + '.wasm'
489                    if os.path.exists(wasm_source):
490                        wasm_output = os.path.splitext(outname)[0] + '.wasm'
491                        file_copied = self.do_copyfile(wasm_source, wasm_output)
492            elif os.path.isdir(fname):
493                fname = os.path.join(d.build_dir, fname.rstrip('/'))
494                outname = os.path.join(outdir, os.path.basename(fname))
495                d.dirmaker.makedirs(outdir, exist_ok=True)
496                self.do_copydir(d, fname, outname, None, install_mode)
497            else:
498                raise RuntimeError('Unknown file type for {!r}'.format(fname))
499            printed_symlink_error = False
500            for alias, to in aliases.items():
501                try:
502                    symlinkfilename = os.path.join(outdir, alias)
503                    try:
504                        os.remove(symlinkfilename)
505                    except FileNotFoundError:
506                        pass
507                    os.symlink(to, symlinkfilename)
508                    append_to_log(self.lf, symlinkfilename)
509                except (NotImplementedError, OSError):
510                    if not printed_symlink_error:
511                        print("Symlink creation does not work on this platform. "
512                              "Skipping all symlinking.")
513                        printed_symlink_error = True
514            if file_copied:
515                self.did_install_something = True
516                try:
517                    depfixer.fix_rpath(outname, t.rpath_dirs_to_remove, install_rpath, final_path,
518                                       install_name_mappings, verbose=False)
519                except SystemExit as e:
520                    if isinstance(e.code, int) and e.code == 0:
521                        pass
522                    else:
523                        raise
524
525def run(opts):
526    datafilename = 'meson-private/install.dat'
527    private_dir = os.path.dirname(datafilename)
528    log_dir = os.path.join(private_dir, '../meson-logs')
529    if not os.path.exists(os.path.join(opts.wd, datafilename)):
530        sys.exit('Install data not found. Run this command in build directory root.')
531    if not opts.no_rebuild:
532        if not rebuild_all(opts.wd):
533            sys.exit(-1)
534    os.chdir(opts.wd)
535    with open(os.path.join(log_dir, 'install-log.txt'), 'w') as lf:
536        installer = Installer(opts, lf)
537        append_to_log(lf, '# List of files installed by Meson')
538        append_to_log(lf, '# Does not contain files installed by custom scripts.')
539        if opts.profile:
540            import cProfile as profile
541            fname = os.path.join(private_dir, 'profile-installer.log')
542            profile.runctx('installer.do_install(datafilename)', globals(), locals(), filename=fname)
543        else:
544            installer.do_install(datafilename)
545    return 0
546