Repository: pypa/pip

Issue:
Using pip with sudo can cause permission issues
With 6.0 pip has started writing to the users home directory by default. This is the right thing to do (tm) however it does have one particular thorny issue. When executing something like `sudo pip install Django` sudo doesn't change the `$HOME` directory to the root user which more or less means that the user is going to get root owned files in their home directory. You can tell sudo to switch the home directory using `sudo -H pip install Django` and everything works fine however the error condition is kind of thorny since it's a non obvious error caused by something that's supposed to be more or less invisible.

The problem comes from the fact that when we do `os.path.expanduser("~/whatever")` Python will check the `$HOME` environment first (which sudo doesn't change by default).

I think that the best way to fix this, is to detect if the directories we're attempting to write into are owned by the user we're running under, and if they are not disable any features that rely on writing to those directories along with a warning about it. This will only "do the wrong thing" if the user has a home directory set to a non-existent directory which is in a directory where they are not the owners but have write permissions. I don't believe this to be a very large group of people, certainly much less than the people using `sudo` without the `-H` flag and the symptoms are way easier to diagnose. In particular the conversation stops being "why is pip writing user owned directories/files to my homedir" and instead becomes "To enable all of the features of pip, use `-H` with `sudo` or ensure that you have a reasonable home directory set".



Discussion:
I should note, that pip has always had this problem with the `~/.pip/debug.log` file, it just wasn't something that everyone was going to hit for most invocations of pip like we have now.


Resolving patch:
diff --git a/pip/basecommand.py b/pip/basecommand.py
--- a/pip/basecommand.py
+++ b/pip/basecommand.py
@@ -23,6 +23,7 @@
 )
 from pip.utils import appdirs, get_prog, normalize_path
 from pip.utils.deprecation import RemovedInPip8Warning
+from pip.utils.filesystem import check_path_owner
 from pip.utils.logging import IndentingFormatter
 from pip.utils.outdated import pip_version_check
 
@@ -113,6 +114,13 @@ def main(self, args):
         else:
             level = "INFO"
 
+        # Compute the path for our debug log.
+        debug_log_path = os.path.join(appdirs.user_log_dir("pip"), "debug.log")
+
+        # Ensure that the path for our debug log is owned by the current user
+        # and if it is not, disable the debug log.
+        write_debug_log = check_path_owner(debug_log_path, os.geteuid())
+
         logging_dictConfig({
             "version": 1,
             "disable_existing_loggers": False,
@@ -136,9 +144,7 @@ def main(self, args):
                 "debug_log": {
                     "level": "DEBUG",
                     "class": "pip.utils.logging.BetterRotatingFileHandler",
-                    "filename": os.path.join(
-                        appdirs.user_log_dir("pip"), "debug.log",
-                    ),
+                    "filename": debug_log_path,
                     "maxBytes": 10 * 1000 * 1000,  # 10 MB
                     "backupCount": 1,
                     "delay": True,
@@ -156,7 +162,7 @@ def main(self, args):
                 "level": level,
                 "handlers": list(filter(None, [
                     "console",
-                    "debug_log",
+                    "debug_log" if write_debug_log else None,
                     "user_log" if options.log else None,
                 ])),
             },
@@ -178,6 +184,17 @@ def main(self, args):
             ),
         })
 
+        # We add this warning here instead of up above, because the logger
+        # hasn't been configured until just now.
+        if not write_debug_log:
+            logger.warning(
+                "The directory '%s' or its parent directory is not owned by "
+                "the current user and the debug log has been disabled. Please "
+                "check the permissions and owner of that directory. If "
+                "executing pip with sudo, you may want the -H flag.",
+                os.path.dirname(debug_log_path),
+            )
+
         if options.log_explicit_levels:
             warnings.warn(
                 "--log-explicit-levels has been deprecated and will be removed"
diff --git a/pip/download.py b/pip/download.py
--- a/pip/download.py
+++ b/pip/download.py
@@ -23,6 +23,7 @@
 from pip.models import PyPI
 from pip.utils import (splitext, rmtree, format_size, display_path,
                        backup_dir, ask_path_exists, unpack_file)
+from pip.utils.filesystem import check_path_owner
 from pip.utils.ui import DownloadProgressBar, DownloadProgressSpinner
 from pip.locations import write_delete_marker_file
 from pip.vcs import vcs
@@ -225,7 +226,31 @@ class SafeFileCache(FileCache):
     not be accessible or writable.
     """
 
+    def __init__(self, *args, **kwargs):
+        super(SafeFileCache, self).__init__(*args, **kwargs)
+
+        # Check to ensure that the directory containing our cache directory
+        # is owned by the user current executing pip. If it does not exist
+        # we will check the parent directory until we find one that does exist.
+        # If it is not owned by the user executing pip then we will disable
+        # the cache and log a warning.
+        if not check_path_owner(self.directory, os.geteuid()):
+            logger.warning(
+                "The directory '%s' or its parent directory is not owned by "
+                "the current user and the cache has been disabled. Please "
+                "check the permissions and owner of that directory. If "
+                "executing pip with sudo, you may want the -H flag.",
+                self.directory,
+            )
+
+            # Set our directory to None to disable the Cache
+            self.directory = None
+
     def get(self, *args, **kwargs):
+        # If we don't have a directory, then the cache should be a no-op.
+        if self.directory is None:
+            return
+
         try:
             return super(SafeFileCache, self).get(*args, **kwargs)
         except (LockError, OSError, IOError):
@@ -235,6 +260,10 @@ def get(self, *args, **kwargs):
             pass
 
     def set(self, *args, **kwargs):
+        # If we don't have a directory, then the cache should be a no-op.
+        if self.directory is None:
+            return
+
         try:
             return super(SafeFileCache, self).set(*args, **kwargs)
         except (LockError, OSError, IOError):
@@ -244,6 +273,10 @@ def set(self, *args, **kwargs):
             pass
 
     def delete(self, *args, **kwargs):
+        # If we don't have a directory, then the cache should be a no-op.
+        if self.directory is None:
+            return
+
         try:
             return super(SafeFileCache, self).delete(*args, **kwargs)
         except (LockError, OSError, IOError):
diff --git a/pip/utils/filesystem.py b/pip/utils/filesystem.py
new file mode 100644
--- /dev/null
+++ b/pip/utils/filesystem.py
@@ -0,0 +1,18 @@
+import os.path
+
+from pip.compat import get_path_uid
+
+
+def check_path_owner(path, uid):
+    previous = None
+    while path != previous:
+        if os.path.lexists(path):
+            # Actually do the ownership check
+            try:
+                if get_path_uid(path) != os.geteuid():
+                    return False
+            except OSError:
+                return False
+            return True
+        else:
+            previous, path = path, os.path.dirname(path)


Test patch: