Repository: mesonbuild/meson

Issue:
Return value of get_option('xxxdir') is inconsistent
Since we started allowing passing absolute paths to `xxxdir` such as `--libdir`, it is difficult to know whether `get_option('libdir')` will return the full path to `libdir` or just the path relative to `prefix`.

Normally this is not a problem because people will use `join_paths([get_option('prefix'), get_option('libdir')])` which will do the right thing when `get_option('libdir')` returns an absolute path. However, there are use-cases where people want to just fetch the value of `libdir` relative to `prefix`.

For instance, Gentoo's init system OpenRC installs libraries in `/usr/lib64` and some of its binaries and scripts (which are not accessible to the user) into `/lib64/rc/bin`, `/lib64/rc/sbin`, `/lib64/rc/sbin`, etc. Gentoo does not use a unified `/usr`, so `/usr/lib64` is not the same as `/lib64`. I can imagine that lots of other 'system-level' projects for distros that don't have a unified `/usr` will face this issue.

There are examples in GStreamer as well besides "non-unified /usr"; just search for `grep --include=meson.build -re 'get_option.*libdir'` inside [gst-build](https://cgit.freedesktop.org/gstreamer/gst-build/) with the subprojects cloned. Perhaps many of those can be 'fixed' or changed, but people will keep writing build files with these issues.

I propose that if `xxxdir` is an absolute path and a subdir of `prefix`, `get_option('xxxdir')` should return the path **relative to `prefix`**. If it is not, only then must it return the absolute path.


Discussion:
Related: #1081 
This is a bit abstract and the real issue is hard to decipher. Could you write a single file example describing:

 - where a file should be installed
 - how people would write the install today
 - how it goes wrong
 - what should happen instead
 - how does the proposed change fix this

Thanks,


Resolving patch:
diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -131,10 +131,54 @@ def __init__(self, options):
         # Only to print a warning if it changes between Meson invocations.
         self.pkgconf_envvar = os.environ.get('PKG_CONFIG_PATH', '')
 
+    def sanitize_prefix(self, prefix):
+        if not os.path.isabs(prefix):
+            raise MesonException('prefix value {!r} must be an absolute path'
+                                 ''.format(prefix))
+        if prefix.endswith('/') or prefix.endswith('\\'):
+            # On Windows we need to preserve the trailing slash if the
+            # string is of type 'C:\' because 'C:' is not an absolute path.
+            if len(prefix) == 3 and prefix[1] == ':':
+                pass
+            else:
+                prefix = prefix[:-1]
+        return prefix
+
+    def sanitize_dir_option_value(self, prefix, option, value):
+        '''
+        If the option is an installation directory option and the value is an
+        absolute path, check that it resides within prefix and return the value
+        as a path relative to the prefix.
+
+        This way everyone can do f.ex, get_option('libdir') and be sure to get
+        the library directory relative to prefix.
+        '''
+        if option.endswith('dir') and os.path.isabs(value) and \
+           option not in builtin_dir_noprefix_options:
+            # Value must be a subdir of the prefix
+            if os.path.commonpath([value, prefix]) != prefix:
+                m = 'The value of the {!r} option is {!r} which must be a ' \
+                    'subdir of the prefix {!r}.\nNote that if you pass a ' \
+                    'relative path, it is assumed to be a subdir of prefix.'
+                raise MesonException(m.format(option, value, prefix))
+            # Convert path to be relative to prefix
+            skip = len(prefix) + 1
+            value = value[skip:]
+        return value
+
     def init_builtins(self, options):
         self.builtins = {}
+        # Sanitize prefix
+        options.prefix = self.sanitize_prefix(options.prefix)
+        # Initialize other builtin options
         for key in get_builtin_options():
-            args = [key] + builtin_options[key][1:-1] + [getattr(options, key, get_builtin_option_default(key))]
+            if hasattr(options, key):
+                value = getattr(options, key)
+                value = self.sanitize_dir_option_value(options.prefix, key, value)
+                setattr(options, key, value)
+            else:
+                value = get_builtin_option_default(key)
+            args = [key] + builtin_options[key][1:-1] + [value]
             self.builtins[key] = builtin_options[key][0](*args)
 
     def get_builtin_option(self, optname):
@@ -143,7 +187,11 @@ def get_builtin_option(self, optname):
         raise RuntimeError('Tried to get unknown builtin option %s.' % optname)
 
     def set_builtin_option(self, optname, value):
-        if optname in self.builtins:
+        if optname == 'prefix':
+            value = self.sanitize_prefix(value)
+        elif optname in self.builtins:
+            prefix = self.builtins['prefix'].value
+            value = self.sanitize_dir_option_value(prefix, optname, value)
             self.builtins[optname].set_value(value)
         else:
             raise RuntimeError('Tried to set unknown builtin option %s.' % optname)
@@ -235,6 +283,9 @@ def get_builtin_option_default(optname):
     'errorlogs':       [UserBooleanOption, "Whether to print the logs from failing tests.", True],
 }
 
+# Installation directories that can reside in a path outside of the prefix
+builtin_dir_noprefix_options = {'sysconfdir', 'localstatedir', 'sharedstatedir'}
+
 forbidden_target_names = {'clean': None,
                           'clean-ctlist': None,
                           'clean-gcno': None,
diff --git a/mesonbuild/mesonmain.py b/mesonbuild/mesonmain.py
--- a/mesonbuild/mesonmain.py
+++ b/mesonbuild/mesonmain.py
@@ -73,15 +73,6 @@ class MesonApp():
 
     def __init__(self, dir1, dir2, script_launcher, handshake, options, original_cmd_line_args):
         (self.source_dir, self.build_dir) = self.validate_dirs(dir1, dir2, handshake)
-        if not os.path.isabs(options.prefix):
-            raise RuntimeError('--prefix value must be an absolute path: {!r}'.format(options.prefix))
-        if options.prefix.endswith('/') or options.prefix.endswith('\\'):
-            # On Windows we need to preserve the trailing slash if the
-            # string is of type 'C:\' because 'C:' is not an absolute path.
-            if len(options.prefix) == 3 and options.prefix[1] == ':':
-                pass
-            else:
-                options.prefix = options.prefix[:-1]
         self.meson_script_launcher = script_launcher
         self.options = options
         self.original_cmd_line_args = original_cmd_line_args
diff --git a/run_unittests.py b/run_unittests.py
--- a/run_unittests.py
+++ b/run_unittests.py
@@ -33,10 +33,11 @@ def get_soname(fname):
             return m.group(1)
     raise RuntimeError('Could not determine soname:\n\n' + raw_out)
 
-def get_fake_options():
+def get_fake_options(prefix):
     import argparse
     opts = argparse.Namespace()
     opts.cross_file = None
+    opts.prefix = prefix
     return opts
 
 class FakeEnvironment(object):
@@ -85,7 +86,8 @@ def tearDown(self):
         super().tearDown()
 
     def _run(self, command):
-        self.output += subprocess.check_output(command, env=os.environ.copy())
+        self.output += subprocess.check_output(command, stderr=subprocess.STDOUT,
+                                               env=os.environ.copy())
 
     def init(self, srcdir, extra_args=None):
         if extra_args is None:
@@ -510,7 +512,7 @@ def test_compiler_c_stds(self):
         '''
         testdir = os.path.join(self.common_test_dir, '1 trivial')
         env = Environment(testdir, self.builddir, self.meson_command,
-                          get_fake_options(), [])
+                          get_fake_options(self.prefix), [])
         cc = env.detect_c_compiler(False)
         self._test_stds_impl(testdir, cc, 'c')
 
@@ -521,11 +523,10 @@ def test_compiler_cpp_stds(self):
         '''
         testdir = os.path.join(self.common_test_dir, '2 cpp')
         env = Environment(testdir, self.builddir, self.meson_command,
-                          get_fake_options(), [])
+                          get_fake_options(self.prefix), [])
         cpp = env.detect_cpp_compiler(False)
         self._test_stds_impl(testdir, cpp, 'cpp')
 
-
     def test_build_by_default(self):
         testdir = os.path.join(self.unit_test_dir, '5 build by default')
         self.init(testdir)
@@ -537,6 +538,21 @@ def test_build_by_default(self):
         self._run(self.ninja_command + ['fooprog'])
         self.assertTrue(os.path.exists(exe))
 
+    def test_libdir_must_be_inside_prefix(self):
+        testdir = os.path.join(self.common_test_dir, '1 trivial')
+        # libdir being inside prefix is ok
+        args = ['--prefix', '/opt', '--libdir', '/opt/lib32']
+        self.init(testdir, args)
+        self.wipe()
+        # libdir not being inside prefix is not ok
+        args = ['--prefix', '/usr', '--libdir', '/opt/lib32']
+        self.assertRaises(subprocess.CalledProcessError, self.init, testdir, args)
+        self.wipe()
+        # libdir must be inside prefix even when set via mesonconf
+        self.init(testdir)
+        self.assertRaises(subprocess.CalledProcessError, self.setconf, '-Dlibdir=/opt')
+
+
 class RewriterTests(unittest.TestCase):
 
     def setUp(self):


Test patch:
diff --git a/test cases/failing/39 libdir must be inside prefix/meson.build b/test cases/failing/39 libdir must be inside prefix/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/failing/39 libdir must be inside prefix/meson.build	
@@ -0,0 +1,2 @@
+project('libdir prefix', 'c',
+  default_options : ['libdir=/opt/lib'])
diff --git a/test cases/failing/40 prefix absolute/meson.build b/test cases/failing/40 prefix absolute/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/failing/40 prefix absolute/meson.build	
@@ -0,0 +1,2 @@
+project('prefix-abs', 'c',
+  default_options : ['prefix=some/path/notabs'])