# Copyright 2013-2023 Lawrence Livermore National Security, LLC and other # Spack Project Developers. See the top-level COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) import os import re import shutil import subprocess import sys import tempfile from llnl.util import lang, tty from ..path import system_path_filter if sys.platform == "win32": from win32file import CreateHardLink is_windows = sys.platform == "win32" def symlink(source_path: str, link_path: str, allow_broken_symlinks: bool = not is_windows): """ Create a link. On non-Windows and Windows with System Administrator privleges this will be a normal symbolic link via os.symlink. On Windows without privledges the link will be a junction for a directory and a hardlink for a file. On Windows the various link types are: Symbolic Link: A link to a file or directory on the same or different volume (drive letter) or even to a remote file or directory (using UNC in its path). Need System Administrator privileges to make these. Hard Link: A link to a file on the same volume (drive letter) only. Every file (file's data) has at least 1 hard link (file's name). But when this method creates a new hard link there will be 2. Deleting all hard links effectively deletes the file. Don't need System Administrator privileges. Junction: A link to a directory on the same or different volume (drive letter) but not to a remote directory. Don't need System Administrator privileges. Parameters: source_path (str): The real file or directory that the link points to. Must be absolute OR relative to the link. link_path (str): The path where the link will exist. allow_broken_symlinks (bool): On Linux or Mac, don't raise an exception if the source_path doesn't exist. This will still raise an exception on Windows. """ source_path = os.path.normpath(source_path) win_source_path = source_path link_path = os.path.normpath(link_path) # Never allow broken links on Windows. if sys.platform == "win32" and allow_broken_symlinks: raise ValueError("allow_broken_symlinks parameter cannot be True on Windows.") if not allow_broken_symlinks: # Perform basic checks to make sure symlinking will succeed if os.path.lexists(link_path): raise AlreadyExistsError( f"Link path ({link_path}) already exists. Cannot create link." ) if not os.path.exists(source_path): if os.path.isabs(source_path) and not allow_broken_symlinks: # An absolute source path that does not exist will result in a broken link. raise SymlinkError( f"Source path ({source_path}) is absolute but does not exist. Resulting " f"link would be broken so not making link." ) else: # os.symlink can create a link when the given source path is relative to # the link path. Emulate this behavior and check to see if the source exists # relative to the link path ahead of link creation to prevent broken # links from being made. link_parent_dir = os.path.dirname(link_path) relative_path = os.path.join(link_parent_dir, source_path) if os.path.exists(relative_path): # In order to work on windows, the source path needs to be modified to be # relative because hardlink/junction dont resolve relative paths the same # way as os.symlink. This is ignored on other operating systems. win_source_path = relative_path elif not allow_broken_symlinks: raise SymlinkError( f"The source path ({source_path}) is not relative to the link path " f"({link_path}). Resulting link would be broken so not making link." ) # Create the symlink if sys.platform == "win32" and not _windows_can_symlink(): _windows_create_link(win_source_path, link_path) else: os.symlink(source_path, link_path, target_is_directory=os.path.isdir(source_path)) def islink(path: str) -> bool: """Override os.islink to give correct answer for spack logic. For Non-Windows: a link can be determined with the os.path.islink method. Windows-only methods will return false for other operating systems. For Windows: spack considers symlinks, hard links, and junctions to all be links, so if any of those are True, return True. Args: path (str): path to check if it is a link. Returns: bool - whether the path is any kind link or not. """ return any([os.path.islink(path), _windows_is_junction(path), _windows_is_hardlink(path)]) def _windows_is_hardlink(path: str) -> bool: """Determines if a path is a windows hard link. This is accomplished by looking at the number of links using os.stat. A non-hard-linked file will have a st_nlink value of 1, whereas a hard link will have a value larger than 1. Note that both the original and hard-linked file will return True because they share the same inode. Args: path (str): Windows path to check for a hard link Returns: bool - Whether the path is a hard link or not. """ if sys.platform != "win32" or os.path.islink(path) or not os.path.exists(path): return False return os.stat(path).st_nlink > 1 def _windows_is_junction(path: str) -> bool: """Determines if a path is a windows junction. A junction can be determined using a bitwise AND operation between the file's attribute bitmask and the known junction bitmask (0x400). Args: path (str): A non-file path Returns: bool - whether the path is a junction or not. """ if sys.platform != "win32" or os.path.islink(path) or os.path.isfile(path): return False import ctypes.wintypes get_file_attributes = ctypes.windll.kernel32.GetFileAttributesW # type: ignore[attr-defined] get_file_attributes.argtypes = (ctypes.wintypes.LPWSTR,) get_file_attributes.restype = ctypes.wintypes.DWORD invalid_file_attributes = 0xFFFFFFFF reparse_point = 0x400 file_attr = get_file_attributes(str(path)) if file_attr == invalid_file_attributes: return False return file_attr & reparse_point > 0 @lang.memoized def _windows_can_symlink() -> bool: """ Determines if windows is able to make a symlink depending on the system configuration and the level of the user's permissions. """ if sys.platform != "win32": tty.warn("windows_can_symlink method can't be used on non-Windows OS.") return False tempdir = tempfile.mkdtemp() dpath = os.path.join(tempdir, "dpath") fpath = os.path.join(tempdir, "fpath.txt") dlink = os.path.join(tempdir, "dlink") flink = os.path.join(tempdir, "flink.txt") import llnl.util.filesystem as fs fs.touchp(fpath) try: os.symlink(dpath, dlink) can_symlink_directories = os.path.islink(dlink) except OSError: can_symlink_directories = False try: os.symlink(fpath, flink) can_symlink_files = os.path.islink(flink) except OSError: can_symlink_files = False # Cleanup the test directory shutil.rmtree(tempdir) return can_symlink_directories and can_symlink_files def _windows_create_link(source: str, link: str): """ Attempts to create a Hard Link or Junction as an alternative to a symbolic link. This is called when symbolic links cannot be created. """ if sys.platform != "win32": raise SymlinkError("windows_create_link method can't be used on non-Windows OS.") elif os.path.isdir(source): _windows_create_junction(source=source, link=link) elif os.path.isfile(source): _windows_create_hard_link(path=source, link=link) else: raise SymlinkError( f"Cannot create link from {source}. It is neither a file nor a directory." ) def _windows_create_junction(source: str, link: str): """Duly verify that the path and link are eligible to create a junction, then create the junction. """ if sys.platform != "win32": raise SymlinkError("windows_create_junction method can't be used on non-Windows OS.") elif not os.path.exists(source): raise SymlinkError("Source path does not exist, cannot create a junction.") elif os.path.lexists(link): raise AlreadyExistsError("Link path already exists, cannot create a junction.") elif not os.path.isdir(source): raise SymlinkError("Source path is not a directory, cannot create a junction.") import subprocess cmd = ["cmd", "/C", "mklink", "/J", link, source] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate() tty.debug(out.decode()) if proc.returncode != 0: err = err.decode() tty.error(err) raise SymlinkError("Make junction command returned a non-zero return code.", err) def _windows_create_hard_link(path: str, link: str): """Duly verify that the path and link are eligible to create a hard link, then create the hard link. """ if sys.platform != "win32": raise SymlinkError("windows_create_hard_link method can't be used on non-Windows OS.") elif not os.path.exists(path): raise SymlinkError(f"File path {path} does not exist. Cannot create hard link.") elif os.path.lexists(link): raise AlreadyExistsError(f"Link path ({link}) already exists. Cannot create hard link.") elif not os.path.isfile(path): raise SymlinkError(f"File path ({link}) is not a file. Cannot create hard link.") else: tty.debug(f"Creating hard link {link} pointing to {path}") CreateHardLink(link, path) def readlink(path: str): """Spack utility to override of os.readlink method to work cross platform""" if _windows_is_hardlink(path): return _windows_read_hard_link(path) elif _windows_is_junction(path): return _windows_read_junction(path) else: return os.readlink(path) def _windows_read_hard_link(link: str) -> str: """Find all of the files that point to the same inode as the link""" if sys.platform != "win32": raise SymlinkError("Can't read hard link on non-Windows OS.") link = os.path.abspath(link) fsutil_cmd = ["fsutil", "hardlink", "list", link] proc = subprocess.Popen(fsutil_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) out, err = proc.communicate() if proc.returncode != 0: raise SymlinkError(f"An error occurred while reading hard link: {err.decode()}") # fsutil response does not include the drive name, so append it back to each linked file. drive, link_tail = os.path.splitdrive(os.path.abspath(link)) links = set([os.path.join(drive, p) for p in out.decode().splitlines()]) links.remove(link) if len(links) == 1: return links.pop() elif len(links) > 1: # TODO: How best to handle the case where 3 or more paths point to a single inode? raise SymlinkError(f"Found multiple paths pointing to the same inode {links}") else: raise SymlinkError("Cannot determine hard link source path.") def _windows_read_junction(link: str): """Find the path that a junction points to.""" if sys.platform != "win32": raise SymlinkError("Can't read junction on non-Windows OS.") link = os.path.abspath(link) link_basename = os.path.basename(link) link_parent = os.path.dirname(link) fsutil_cmd = ["dir", "/a:l", link_parent] proc = subprocess.Popen(fsutil_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) out, err = proc.communicate() if proc.returncode != 0: raise SymlinkError(f"An error occurred while reading junction: {err.decode()}") matches = re.search(rf"\s+{link_basename} \[(.*)]", out.decode()) if matches: return matches.group(1) else: raise SymlinkError("Could not find junction path.") @system_path_filter def resolve_link_target_relative_to_the_link(link): """ os.path.isdir uses os.path.exists, which for links will check the existence of the link target. If the link target is relative to the link, we need to construct a pathname that is valid from our cwd (which may not be the same as the link's directory) """ target = readlink(link) if os.path.isabs(target): return target link_dir = os.path.dirname(os.path.abspath(link)) return os.path.join(link_dir, target) class SymlinkError(RuntimeError): """Exception class for errors raised while creating symlinks, junctions and hard links """ class AlreadyExistsError(SymlinkError): """Link path already exists."""